Compare commits

..

10 Commits

Author SHA1 Message Date
RIT Services
38453e7f6d Server commit 2026-08-04 06:15:58 +00:00
RIT Services
fafb8d5bce Server Configured 2026-08-03 12:28:42 +00:00
Sidharth Prabhu
9f419a2503 Fixed some stuff 2026-08-03 10:57:17 +05:30
Sidharth Prabhu
6b3714f2bc User setup added 2026-08-03 10:41:57 +05:30
Sidharth Prabhu
28a13d619c Updated API Dashboard 2026-07-29 11:52:09 +05:30
Sidharth Prabhu
ba06c1a7a6 Payment Gateway added 2026-07-24 12:06:56 +05:30
Sidharth Prabhu
f8bd97229b Google Sign in added 2026-07-24 11:21:35 +05:30
Sidharth Prabhu
93e375dc7d API Dashboard added 2026-07-24 10:27:30 +05:30
Sidharth Prabhu
2c42aeb4ee Tillo Rebranding 2026-07-24 08:38:04 +05:30
Sidharth Prabhu
4f3c39fee8 Updated Readme 2026-06-25 18:48:52 +05:30
16695 changed files with 2718127 additions and 2057 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
*.log
.env.*
backend/razorpay.env
backend/src/main/resources/application-local.properties
counter-frontend/.env
ordering-site/*
ordering_site/
counter-frontend/

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# Positeasy Clone - Canteen Automation Ecosystem
# Tillo POS Software
Welcome to the **Positeasy Clone**, a comprehensive Canteen Automation Ecosystem. This project is designed to handle point-of-sale (POS), inventory management, user ordering, and administrative tasks for canteen operations.
Welcome to the **Tillo POS Software**, a comprehensive Canteen Automation Ecosystem. This project is designed to handle point-of-sale (POS), inventory management, user ordering, and administrative tasks for canteen operations.
## 🏗️ Ecosystem Architecture

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

BIN
Ritz/.DS_Store vendored

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

24
api-dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

49
api-dashboard/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Tillo Developer Console (API Dashboard)
Welcome to the **Tillo Developer Console**, a premium management dashboard and integration interface for creating, managing, and testing developer API Keys for external system widgets and program modules.
## 🏗️ Architecture & Security Model
To protect sensitive canteen financials and limit key usage footprint, this module operates under a robust security architecture:
1. **Usage Quota Limits**: Each system administrator or customer user is strictly limited to obtaining a maximum of **3 active API keys**.
2. **Access Control Scopes**: API Keys obtain a specialized **Read-Only** access scope. Write operations, wallet top-ups, session creations, or master configurations are rejected with a HTTP `401 Unauthorized` or `403 Forbidden` response.
3. **Financial Exclusion Guard**: All developer API key requests are barred from viewing financial statistics (e.g. daily store revenue, hourly billing graphs, transaction tables, master ledgers, and vendor settlement logs). General non-sensitive counters like active product pricing lists, public store stalls, and order statuses are open for querying.
4. **Wallet Safeguard**:
- **Customer API Keys** can query the active wallet balance of the owner user account.
- **System API Keys** can query general system token circulation statistics, but cannot access individual customer transaction lines or private ledger balances.
---
## 🚀 Getting Started
### 1. Run the Dev Server
Navigate to this directory and boot Vite:
```bash
npm run dev
```
### 2. Authentication
Log in with either:
- **System Admin / Staff Credentials**: email/password credentials.
- **Customer Mobile Credentials**: 10-digit mobile number and 4-digit PIN.
*Credentials matches your core Tillo application database.*
---
## 📡 API Reference Directory
Include the custom header `X-Developer-Key: DEV-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` in all requests to query:
| Method | Path | Description | Access Level |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/developer/v1/validate` | Verify API key health and view owner details. | Key validated |
| `GET` | `/api/developer/v1/stalls` | Fetch catalog listings of active canteen stalls. | Key validated |
| `GET` | `/api/developer/v1/products` | Retrieve catalog list of products and current stock. | Key validated |
| `GET` | `/api/developer/v1/wallet` | Fetch wallet balance (owner only) or total circulation volume. | Owner scope |
| `GET` | `/api/developer/v1/orders` | Retrieve list of active orders (owner only) or order statuses. | Owner scope |
---
*Developed by the Canteen Automation Team.*

View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
api-dashboard/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>api-dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3755
api-dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "api-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"firebase": "^12.16.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

1385
api-dashboard/src/App.css Normal file

File diff suppressed because it is too large Load Diff

1927
api-dashboard/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,16 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
const firebaseConfig = {
apiKey: "AIzaSyDOEiKtH-gs2nAx8Di45wJf8CY5nPm4xPE",
authDomain: "tillo-c8de9.firebaseapp.com",
projectId: "tillo-c8de9",
storageBucket: "tillo-c8de9.firebasestorage.app",
messagingSenderId: "200333262782",
appId: "1:200333262782:web:0d988352ee6a44d7a943ef",
measurementId: "G-YB01EQT2VW"
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();

View File

@@ -0,0 +1,73 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
:root {
--font-sans: "Inter", "system-ui", "-apple-system", "sans-serif";
--color-primary: #08a850;
--color-primary-light: #0cb859;
--color-primary-dark: #068d42;
--color-primary-glow: rgba(8, 168, 80, 0.06);
--color-bg-main: #f4fbf7;
--color-bg-sidebar: #ffffff;
--color-text-dark: #001828;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-border: rgba(0, 24, 40, 0.08);
--color-border-light: rgba(0, 24, 40, 0.04);
--color-danger: #ef4444;
--color-danger-glow: rgba(239, 68, 68, 0.08);
--color-warning: #ea580c;
--color-warning-glow: rgba(234, 88, 12, 0.08);
--color-card-bg: #ffffff;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03);
--shadow-xl: 0 20px 25px -5px rgba(8, 168, 80, 0.04), 0 10px 10px -5px rgba(8, 168, 80, 0.02);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
border-color: var(--color-border);
}
body {
font-family: var(--font-sans);
background-color: var(--color-bg-main);
color: var(--color-text-primary);
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Custom Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-muted);
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

6
backend/.gitignore vendored
View File

@@ -31,3 +31,9 @@ build/
### VS Code ###
.vscode/
### Razorpay secrets (NEVER commit live keys) ###
razorpay.env
### Logs ###
*.log

View File

@@ -87,6 +87,12 @@
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<!-- Razorpay Payments -->
<dependency>
<groupId>com.razorpay</groupId>
<artifactId>razorpay-java</artifactId>
<version>1.4.8</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,8 @@
# Copy to razorpay.env (gitignored) and fill in your Razorpay live keys.
# Source before starting the backend:
# set -a && source razorpay.env && set +a && ./mvnw spring-boot:run
export RAZORPAY_KEY_ID=rzp_live_xxxxxxxx
export RAZORPAY_KEY_SECRET=your_secret_here
# Optional — set after creating a webhook in Razorpay Dashboard
# export RAZORPAY_WEBHOOK_SECRET=whsec_xxxxxxxx

22
backend/run-with-razorpay.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Start the backend with Razorpay credentials loaded from razorpay.env
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -f razorpay.env ]]; then
echo "Missing razorpay.env — copy razorpay.env.example and fill in your keys."
exit 1
fi
set -a
# shellcheck disable=SC1091
source ./razorpay.env
set +a
if [[ -z "${RAZORPAY_KEY_ID:-}" || -z "${RAZORPAY_KEY_SECRET:-}" ]]; then
echo "RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET must be set in razorpay.env"
exit 1
fi
echo "Starting backend with Razorpay key: ${RAZORPAY_KEY_ID:0:12}..."
exec ./mvnw spring-boot:run

View File

@@ -0,0 +1,61 @@
package com.rit.canteen.sales.config;
import com.rit.canteen.sales.model.DeveloperApiKey;
import com.rit.canteen.sales.model.DeveloperApiLog;
import com.rit.canteen.sales.repository.DeveloperApiKeyRepository;
import com.rit.canteen.sales.repository.DeveloperApiLogRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Optional;
@Component
public class DeveloperApiLogInterceptor implements HandlerInterceptor {
@Autowired
private DeveloperApiKeyRepository keyRepository;
@Autowired
private DeveloperApiLogRepository logRepository;
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String uri = request.getRequestURI();
if (uri.startsWith("/api/developer/v1/")) {
String apiKeyHeader = request.getHeader("X-Developer-Key");
String method = request.getMethod();
int status = response.getStatus();
String clientIp = request.getHeader("X-Forwarded-For");
if (clientIp == null || clientIp.isEmpty()) {
clientIp = request.getRemoteAddr();
}
DeveloperApiLog log = new DeveloperApiLog();
log.setEndpoint(uri);
log.setMethod(method);
log.setStatus(status);
log.setClientIp(clientIp);
String responseBody = (String) request.getAttribute("developer_api_response_body");
if (responseBody != null) {
log.setResponseBody(responseBody);
}
if (apiKeyHeader != null && !apiKeyHeader.trim().isEmpty()) {
log.setApiKey(apiKeyHeader);
Optional<DeveloperApiKey> keyOpt = keyRepository.findByApiKey(apiKeyHeader);
if (keyOpt.isPresent()) {
DeveloperApiKey key = keyOpt.get();
log.setAppId(key.getAppId());
log.setUserId(key.getUserId());
log.setUserType(key.getUserType());
}
}
logRepository.save(log);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.rit.canteen.sales.config;
import com.rit.canteen.sales.controller.DeveloperApiController;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
@ControllerAdvice(assignableTypes = {DeveloperApiController.class})
public class DeveloperApiResponseAdvice implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
try {
if (request instanceof ServletServerHttpRequest servletRequest) {
HttpServletRequest httpReq = servletRequest.getServletRequest();
if (body != null) {
String json = objectMapper.writeValueAsString(body);
httpReq.setAttribute("developer_api_response_body", json);
}
}
} catch (Exception e) {
// ignore
}
return body;
}
}

View File

@@ -44,6 +44,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/firebase-login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/check-email").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
@@ -60,6 +62,10 @@ public class SecurityConfig {
// ── PUBLIC: Device log ingestion (ESP32 Bill-Bot devices, no JWT) ──
.requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll()
// ── PUBLIC: Razorpay webhook (authenticated via X-Razorpay-Signature) ──
.requestMatchers(HttpMethod.POST, "/api/payments/webhook").permitAll()
.requestMatchers(HttpMethod.GET, "/api/payments/config").permitAll()
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
@@ -74,6 +80,9 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/wallet/topup").authenticated()
.requestMatchers(HttpMethod.POST, "/api/payments/create").authenticated()
.requestMatchers(HttpMethod.POST, "/api/payments/verify").authenticated()
.requestMatchers(HttpMethod.GET, "/api/payments/history").authenticated()
.requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated()
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated()
@@ -82,6 +91,9 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.PUT, "/api/auth/users/*").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
.requestMatchers(HttpMethod.POST, "/api/orders/*/cancel").authenticated()
.requestMatchers("/api/developer-keys/**").authenticated()
.requestMatchers("/api/developer-apps/**").authenticated()
.requestMatchers("/api/developer/v1/**").permitAll()
.requestMatchers("/api/counter/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
// ── STAFF/MANAGER/MASTER: All other management APIs ──
@@ -108,6 +120,7 @@ public class SecurityConfig {
List<String> origins = Arrays.asList(allowedOriginsStr.split(","));
configuration.setAllowedOrigins(origins);
configuration.setAllowedOriginPatterns(List.of(
"*",
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*:*",

View File

@@ -1,13 +1,18 @@
package com.rit.canteen.sales.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* WebConfig intentionally left minimal.
* CORS is now fully managed by SecurityConfig.corsConfigurationSource()
* to avoid duplicate/conflicting CORS headers.
*/
@Configuration
public class WebConfig {
// CORS handled by SecurityConfig — do not add CorsRegistry here
public class WebConfig implements WebMvcConfigurer {
@Autowired
private DeveloperApiLogInterceptor logInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor).addPathPatterns("/api/developer/v1/**");
}
}

View File

@@ -0,0 +1,515 @@
package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.model.*;
import com.rit.canteen.sales.repository.*;
import com.rit.canteen.sales.service.DeveloperApiKeyService;
import com.rit.canteen.sales.service.TokenService;
import com.rit.canteen.sales.service.SystemNotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/developer/v1")
public class DeveloperApiController {
@Autowired
private DeveloperApiKeyService keyService;
@Autowired
private StallRepository stallRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private TokenTransactionRepository transactionRepository;
@Autowired
private TokenService tokenService;
@Autowired
private SystemNotificationService notificationService;
@Autowired
private StockUpdateController stockUpdateController;
// ── Helper to authenticate key ─────────────────────────────────────────
private DeveloperApiKey authenticate(String headerKey) {
if (headerKey == null || headerKey.trim().isEmpty()) {
return null;
}
return keyService.validateAndUseKey(headerKey).orElse(null);
}
private boolean checkPermission(DeveloperApiKey key, String requiredScope) {
if (key == null) return false;
// If the operation requires write access but the key doesn't have it, block immediately.
if (requiredScope.startsWith("WRITE_") && !key.isWriteAccess()) {
return false;
}
// If it's a customer key, they only get default read-only scopes.
if (!"SYSTEM".equalsIgnoreCase(key.getUserType())) {
return requiredScope.startsWith("READ_");
}
// System users get custom scopes.
String scopes = key.getPermissions();
if (scopes == null || scopes.trim().isEmpty()) {
return false;
}
return Arrays.stream(scopes.split(","))
.map(String::trim)
.anyMatch(scope -> scope.equalsIgnoreCase(requiredScope));
}
// ── 1. Validate API Key ────────────────────────────────────────────────
@GetMapping("/validate")
public ResponseEntity<?> validateKey(@RequestHeader(value = "X-Developer-Key", required = false) String headerKey) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
return ResponseEntity.ok(Map.of(
"status", "VALID",
"name", key.getName(),
"ownerType", key.getUserType(),
"createdAt", key.getCreatedAt(),
"readOnly", !key.isWriteAccess(),
"permissions", key.getPermissions() != null ? Arrays.asList(key.getPermissions().split(",")) : Collections.emptyList()
));
}
// ── 2. Get Stalls (Read-only, non-financial) ───────────────────────────
@GetMapping("/stalls")
public ResponseEntity<?> getStalls(@RequestHeader(value = "X-Developer-Key", required = false) String headerKey) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "READ_STALLS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: READ_STALLS permission required"));
}
List<Stall> stalls = stallRepository.findAll();
// Map to DTO to avoid circular references and hide sensitive fields
List<Map<String, Object>> result = stalls.stream().map(s -> {
Map<String, Object> map = new HashMap<>();
map.put("id", s.getId());
map.put("name", s.getName());
map.put("description", s.getDescription());
map.put("active", s.isActive());
map.put("temporarilyClosed", s.isTemporarilyClosed());
return map;
}).collect(Collectors.toList());
return ResponseEntity.ok(result);
}
// ── 3. Get Products (Read-only, non-financial) ─────────────────────────
@GetMapping("/products")
public ResponseEntity<?> getProducts(@RequestHeader(value = "X-Developer-Key", required = false) String headerKey) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "READ_PRODUCTS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: READ_PRODUCTS permission required"));
}
List<Product> products = productRepository.findAll();
List<Map<String, Object>> result = products.stream().map(p -> {
Map<String, Object> map = new HashMap<>();
map.put("id", p.getId());
map.put("productId", p.getProductId());
map.put("name", p.getName());
map.put("category", p.getCategory());
map.put("price", p.getPrice()); // List price is fine for catalog, not a financial report
map.put("stock", p.getStock());
map.put("active", p.isActive());
map.put("isDraft", p.isDraft());
return map;
}).collect(Collectors.toList());
return ResponseEntity.ok(result);
}
@GetMapping("/wallet")
public ResponseEntity<?> getWallet(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@RequestParam(required = false) String mobileNumber,
@RequestParam(required = false) Long userId) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "READ_WALLETS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: READ_WALLETS permission required"));
}
// ── Check if checking balance for another user by mobileNumber or userId ──
if (mobileNumber != null || userId != null) {
Optional<User> targetUserOpt = Optional.empty();
if (userId != null) {
targetUserOpt = userRepository.findById(userId);
} else {
targetUserOpt = userRepository.findByMobileNumber(mobileNumber);
}
if (targetUserOpt.isPresent()) {
User user = targetUserOpt.get();
return ResponseEntity.ok(Map.of(
"userMobile", user.getMobileNumber(),
"userName", user.getName() != null ? user.getName() : "Customer",
"ritzTokenBalance", user.getRitzTokenBalance(),
"currency", "Ritz Token",
"queryScope", "SPECIFIC_USER"
));
} else {
return ResponseEntity.status(404).body(Map.of("error", "Target user not found"));
}
}
if ("CUSTOMER".equals(key.getUserType())) {
// Customer key: return their specific wallet balance
Optional<User> userOpt = userRepository.findById(key.getUserId());
if (userOpt.isPresent()) {
User user = userOpt.get();
return ResponseEntity.ok(Map.of(
"userMobile", user.getMobileNumber(),
"userName", user.getName() != null ? user.getName() : "Customer",
"ritzTokenBalance", user.getRitzTokenBalance(),
"currency", "Ritz Token",
"queryScope", "OWNER"
));
}
return ResponseEntity.status(404).body(Map.of("error", "Owner user not found"));
} else {
// System key: return overall token circulation stats, but NO individual user accounts or detailed ledgers
List<User> allUsers = userRepository.findAll();
BigDecimal totalCirculation = allUsers.stream()
.map(User::getRitzTokenBalance)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return ResponseEntity.ok(Map.of(
"scope", "SYSTEM_CIRCULATION",
"activeWalletsCount", allUsers.size(),
"totalCirculationBalance", totalCirculation,
"currency", "Ritz Token",
"queryScope", "SYSTEM"
));
}
}
// ── 5. Get Orders (requires READ_ORDERS) ────────────────────────────────
@GetMapping("/orders")
public ResponseEntity<?> getOrders(@RequestHeader(value = "X-Developer-Key", required = false) String headerKey) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "READ_ORDERS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: READ_ORDERS permission required"));
}
List<Order> orders;
if ("CUSTOMER".equals(key.getUserType())) {
orders = orderRepository.findByUserIdOrderByCreatedAtDesc(key.getUserId());
} else {
orders = orderRepository.findAll();
}
// Map to DTO. Financial details (totalAmount, paymentMethod, item prices) are only exposed for staff/SYSTEM keys.
List<Map<String, Object>> result = orders.stream().map(o -> {
Map<String, Object> map = new HashMap<>();
map.put("id", o.getId());
map.put("orderNumber", o.getOrderNumber());
map.put("displayOrderId", o.getDisplayOrderId());
map.put("status", o.getStatus());
map.put("createdAt", o.getCreatedAt());
map.put("orderType", o.getOrderType());
if ("SYSTEM".equalsIgnoreCase(key.getUserType())) {
map.put("totalAmount", o.getTotalAmount());
map.put("paymentMethod", o.getPaymentMethod());
}
List<Map<String, Object>> itemsList = o.getItems().stream().map(item -> {
Map<String, Object> itemMap = new HashMap<>();
itemMap.put("productName", item.getProductName());
itemMap.put("quantity", item.getQuantity());
itemMap.put("stallName", item.getStallName());
if ("SYSTEM".equalsIgnoreCase(key.getUserType())) {
itemMap.put("price", item.getPrice());
}
return itemMap;
}).collect(Collectors.toList());
map.put("items", itemsList);
return map;
}).collect(Collectors.toList());
return ResponseEntity.ok(result);
}
// ── 5a. Get Wallet Transactions (requires READ_WALLETS & SYSTEM key) ─────
@GetMapping("/wallet/transactions")
public ResponseEntity<?> getWalletTransactions(@RequestHeader(value = "X-Developer-Key", required = false) String headerKey) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!"SYSTEM".equalsIgnoreCase(key.getUserType())) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: Only staff keys can view general transaction records"));
}
if (!checkPermission(key, "READ_WALLETS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: READ_WALLETS permission required"));
}
return ResponseEntity.ok(tokenService.getAllTransactions());
}
// ── 5b. Get Wallet Circulation Stats (requires READ_WALLETS & SYSTEM key) ──
@GetMapping("/wallet/stats")
public ResponseEntity<?> getWalletStats(@RequestHeader(value = "X-Developer-Key", required = false) String headerKey) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!"SYSTEM".equalsIgnoreCase(key.getUserType())) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: Only staff keys can view wallet statistics"));
}
if (!checkPermission(key, "READ_WALLETS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: READ_WALLETS permission required"));
}
return ResponseEntity.ok(tokenService.getGlobalStats());
}
// ── 6. Create Stall (requires WRITE_STALLS) ───────────────────────────
@PostMapping("/stalls")
public ResponseEntity<?> createStall(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@RequestBody Stall stall) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_STALLS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_STALLS permission required"));
}
Stall saved = stallRepository.save(stall);
return ResponseEntity.status(201).body(saved);
}
// ── 7. Update Stall (requires WRITE_STALLS) ───────────────────────────
@PutMapping("/stalls/{id}")
@Transactional
public ResponseEntity<?> updateStall(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@PathVariable Long id,
@RequestBody Stall updatedStall) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_STALLS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_STALLS permission required"));
}
return stallRepository.findById(id).map(stall -> {
stall.setName(updatedStall.getName());
stall.setDescription(updatedStall.getDescription());
stall.setActive(updatedStall.isActive());
stall.setTemporarilyClosed(updatedStall.isTemporarilyClosed());
stall.setSessionOptional(updatedStall.isSessionOptional());
return ResponseEntity.ok(stallRepository.save(stall));
}).orElse(ResponseEntity.notFound().build());
}
// ── 8. Delete Stall (requires WRITE_STALLS) ───────────────────────────
@DeleteMapping("/stalls/{id}")
public ResponseEntity<?> deleteStall(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@PathVariable Long id) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_STALLS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_STALLS permission required"));
}
return stallRepository.findById(id).map(stall -> {
stallRepository.delete(stall);
return ResponseEntity.ok(Map.of("success", true, "message", "Stall deleted successfully"));
}).orElse(ResponseEntity.notFound().build());
}
// ── 9. Create Product (requires WRITE_PRODUCTS) ───────────────────────
@PostMapping("/products")
@Transactional
public ResponseEntity<?> createProduct(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@RequestBody Product product) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_PRODUCTS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_PRODUCTS permission required"));
}
Product saved = productRepository.save(product);
if (saved.isDraft()) {
notificationService.createNotification(
"Draft Product Created via API",
"A product draft '" + saved.getName() + "' was created via developer API key.",
"PRODUCT",
"/inventory/products"
);
}
return ResponseEntity.status(201).body(saved);
}
// ── 10. Update Product (requires WRITE_PRODUCTS) ───────────────────────
@PutMapping("/products/{id}")
@Transactional
public ResponseEntity<?> updateProduct(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@PathVariable Long id,
@RequestBody Product details) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_PRODUCTS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_PRODUCTS permission required"));
}
return productRepository.findById(id).map(p -> {
p.setProductId(details.getProductId());
p.setName(details.getName());
p.setCategory(details.getCategory());
p.setDescription(details.getDescription());
p.setPrice(details.getPrice());
p.setStock(details.getStock());
p.setActive(details.isActive());
p.setVeg(details.isVeg());
Product updated = productRepository.save(p);
stockUpdateController.broadcastStockUpdate(updated.getId(), updated.getStock());
return ResponseEntity.ok(updated);
}).orElse(ResponseEntity.notFound().build());
}
// ── 11. Delete Product (requires WRITE_PRODUCTS) ──────────────────────
@DeleteMapping("/products/{id}")
public ResponseEntity<?> deleteProduct(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@PathVariable Long id) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_PRODUCTS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_PRODUCTS permission required"));
}
return productRepository.findById(id).map(p -> {
productRepository.delete(p);
return ResponseEntity.ok(Map.of("success", true, "message", "Product deleted successfully"));
}).orElse(ResponseEntity.notFound().build());
}
// ── 12. Update Order Status (requires WRITE_ORDERS) ───────────────────
@PatchMapping("/orders/{id}/status")
@Transactional
public ResponseEntity<?> updateOrderStatus(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@PathVariable Long id,
@RequestBody Map<String, String> body) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_ORDERS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_ORDERS permission required"));
}
String newStatus = body.get("status");
if (newStatus == null || newStatus.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Status field is required"));
}
return orderRepository.findById(id).map(order -> {
String oldStatus = order.getStatus();
String nextStatus = newStatus.toUpperCase();
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(),
order.getTotalAmount(), "Status changed to CANCELLED via API");
}
}
order.setStatus(nextStatus);
orderRepository.save(order);
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
}).orElse(ResponseEntity.notFound().build());
}
// ── 13. Wallet Topup (requires WRITE_WALLETS) ──────────────────────────
@PostMapping("/wallet/topup")
@Transactional
public ResponseEntity<?> walletTopup(
@RequestHeader(value = "X-Developer-Key", required = false) String headerKey,
@RequestBody Map<String, Object> body) {
DeveloperApiKey key = authenticate(headerKey);
if (key == null) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or missing X-Developer-Key header"));
}
if (!checkPermission(key, "WRITE_WALLETS")) {
return ResponseEntity.status(403).body(Map.of("error", "Access Denied: WRITE_WALLETS permission required"));
}
try {
Long targetUserId = null;
if (body.containsKey("userId") && body.get("userId") != null) {
targetUserId = Long.valueOf(body.get("userId").toString());
} else if (body.containsKey("mobileNumber") && body.get("mobileNumber") != null) {
String mobile = body.get("mobileNumber").toString();
User targetUser = userRepository.findByMobileNumber(mobile).orElse(null);
if (targetUser != null) {
targetUserId = targetUser.getId();
} else {
return ResponseEntity.status(404).body(Map.of("error", "User with mobile number " + mobile + " not found"));
}
} else {
return ResponseEntity.badRequest().body(Map.of("error", "userId or mobileNumber is required"));
}
BigDecimal amount = new BigDecimal(body.get("amount").toString());
if (amount.compareTo(new BigDecimal("50")) < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Minimum top up amount is 50 tokens"));
}
if (amount.compareTo(new BigDecimal("5000")) > 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Single transaction limit exceeded (Max: 5000 tokens)"));
}
String ref = body.getOrDefault("referenceId", "API-TOPUP-" + System.currentTimeMillis()).toString();
User updatedUser = tokenService.topUp(targetUserId, amount, ref);
return ResponseEntity.ok(Map.of(
"success", true,
"newBalance", updatedUser.getRitzTokenBalance(),
"message", "Successfully added " + amount + " Ritz Tokens via API Key"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,156 @@
package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.model.DeveloperApiKey;
import com.rit.canteen.sales.service.DeveloperApiKeyService;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import com.rit.canteen.sales.model.DeveloperApiLog;
import com.rit.canteen.sales.repository.DeveloperApiLogRepository;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/developer-keys")
public class DeveloperApiKeyController {
@Autowired
private DeveloperApiKeyService keyService;
@Autowired
private DeveloperApiLogRepository logRepository;
@GetMapping("/logs")
public ResponseEntity<?> listLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(
page, size, org.springframework.data.domain.Sort.by("timestamp").descending());
org.springframework.data.domain.Page<DeveloperApiLog> logPage = logRepository.findByUserIdAndUserType(
context.userId, context.userType, pageable);
return ResponseEntity.ok(Map.of(
"content", logPage.getContent(),
"currentPage", logPage.getNumber(),
"totalItems", logPage.getTotalElements(),
"totalPages", logPage.getTotalPages(),
"pageSize", logPage.getSize()
));
}
@GetMapping
public ResponseEntity<?> listKeys() {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
List<DeveloperApiKey> keys = keyService.getKeysForUser(context.userId, context.userType);
return ResponseEntity.ok(keys);
}
@PostMapping
public ResponseEntity<?> createKey(@RequestBody Map<String, Object> body) {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
String name = body.containsKey("name") && body.get("name") != null
? body.get("name").toString()
: "My API Key";
boolean writeAccess = false;
String permissions = "";
if ("SYSTEM".equalsIgnoreCase(context.userType)) {
Object writeVal = body.get("writeAccess");
if (writeVal instanceof Boolean) {
writeAccess = (Boolean) writeVal;
} else if (writeVal != null) {
writeAccess = Boolean.parseBoolean(writeVal.toString());
}
permissions = body.containsKey("permissions") && body.get("permissions") != null
? body.get("permissions").toString()
: "";
} else {
permissions = "READ_PRODUCTS,READ_STALLS,READ_ORDERS,READ_WALLETS";
}
Long appId = null;
if (body.containsKey("appId") && body.get("appId") != null) {
try {
appId = Long.valueOf(body.get("appId").toString());
} catch (NumberFormatException e) {
// ignore
}
}
try {
DeveloperApiKey newKey = keyService.createKey(context.userId, context.userType, context.identifier, name, writeAccess, permissions, appId);
return ResponseEntity.status(201).body(newKey);
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteKey(@PathVariable Long id) {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
try {
keyService.deleteKey(id, context.userId, context.userType);
return ResponseEntity.ok(Map.of("success", true, "message", "API Key revoked successfully"));
} catch (SecurityException e) {
return ResponseEntity.status(403).body(Map.of("error", e.getMessage()));
}
}
// ── Helper UserContext parser ──────────────────────────────────────────
private UserContext getUserContext() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return null;
if (auth.getDetails() instanceof Claims claims) {
Long userId;
Object uid = claims.get("userId");
if (uid instanceof Integer) {
userId = ((Integer) uid).longValue();
} else if (uid instanceof Long) {
userId = (Long) uid;
} else if (uid != null) {
userId = Long.valueOf(uid.toString());
} else {
return null;
}
String type = (String) claims.get("type");
String userType = "customer".equals(type) ? "CUSTOMER" : "SYSTEM";
String identifier = claims.getSubject();
return new UserContext(userId, userType, identifier);
}
return null;
}
private static class UserContext {
final Long userId;
final String userType;
final String identifier;
UserContext(Long userId, String userType, String identifier) {
this.userId = userId;
this.userType = userType;
this.identifier = identifier;
}
}
}

View File

@@ -0,0 +1,129 @@
package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.model.DeveloperApp;
import com.rit.canteen.sales.repository.DeveloperAppRepository;
import com.rit.canteen.sales.repository.DeveloperApiKeyRepository;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/developer-apps")
public class DeveloperAppController {
@Autowired
private DeveloperAppRepository appRepository;
@Autowired
private DeveloperApiKeyRepository keyRepository;
@GetMapping
public ResponseEntity<?> listApps() {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
List<DeveloperApp> apps = appRepository.findByUserIdAndUserType(context.userId, context.userType);
return ResponseEntity.ok(apps);
}
@PostMapping
public ResponseEntity<?> createApp(@RequestBody Map<String, Object> body) {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
String name = body.containsKey("name") && body.get("name") != null
? body.get("name").toString()
: "";
String description = body.containsKey("description") && body.get("description") != null
? body.get("description").toString()
: "";
if (name.trim().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "App name is required"));
}
DeveloperApp app = new DeveloperApp();
app.setName(name);
app.setDescription(description);
app.setUserId(context.userId);
app.setUserType(context.userType);
app.setOwnerIdentifier(context.identifier);
DeveloperApp saved = appRepository.save(app);
return ResponseEntity.status(201).body(saved);
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> deleteApp(@PathVariable Long id) {
UserContext context = getUserContext();
if (context == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
Optional<DeveloperApp> appOpt = appRepository.findById(id);
if (appOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
DeveloperApp app = appOpt.get();
if (!app.getUserId().equals(context.userId) || !app.getUserType().equals(context.userType)) {
return ResponseEntity.status(403).body(Map.of("error", "Unauthorized to delete this App"));
}
// Delete keys associated with this App first
keyRepository.deleteByAppId(id);
appRepository.delete(app);
return ResponseEntity.ok(Map.of("success", true, "message", "App and its keys deleted successfully"));
}
// ── Helper UserContext parser ──────────────────────────────────────────
private UserContext getUserContext() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return null;
if (auth.getDetails() instanceof Claims claims) {
Long userId;
Object uid = claims.get("userId");
if (uid instanceof Integer) {
userId = ((Integer) uid).longValue();
} else if (uid instanceof Long) {
userId = (Long) uid;
} else if (uid != null) {
userId = Long.valueOf(uid.toString());
} else {
return null;
}
String type = (String) claims.get("type");
String userType = "customer".equals(type) ? "CUSTOMER" : "SYSTEM";
String identifier = claims.getSubject();
return new UserContext(userId, userType, identifier);
}
return null;
}
private static class UserContext {
final Long userId;
final String userType;
final String identifier;
UserContext(Long userId, String userType, String identifier) {
this.userId = userId;
this.userType = userType;
this.identifier = identifier;
}
}
}

View File

@@ -48,6 +48,9 @@ public class OrderController {
@Autowired
private TokenService tokenService;
@Autowired
private com.rit.canteen.sales.service.OrderPlacementService orderPlacementService;
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
@@ -138,105 +141,42 @@ public class OrderController {
order.setOrderType("POS");
}
if (order.getItems() == null || order.getItems().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
}
// ── SECURITY: Server-side price verification ──────────────────────
BigDecimal serverTotal = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
if (item.getProductId() != null) {
Optional<Product> productOpt = productRepository.findById(item.getProductId());
if (productOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "Product not found: " + item.getProductId()));
}
Product product = productOpt.get();
// Use offer price if present, otherwise base price
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
? product.getOfferPrice() : product.getPrice();
// Add parcel fee if selected and parcellable
if (item.getProductName() != null && item.getProductName().endsWith(" (Parcel)") && product.isParcellable()) {
unitPrice = unitPrice.add(BigDecimal.valueOf(5));
}
// Update item's saved price so it reflects the unitPrice + parcel fee
item.setPrice(unitPrice);
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
}
}
// Allow ±5 tolerance for rounding differences
if (serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
// Customers cannot mark an order as paid via RAZORPAY without going through PaymentController
if (!isStaff() && order.getPaymentMethod() != null
&& !"RITZ_TOKEN".equalsIgnoreCase(order.getPaymentMethod())) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "Price mismatch detected. Please refresh and try again.",
"serverTotal", serverTotal,
"clientTotal", order.getTotalAmount()
"message", "Online payments must be completed via Razorpay checkout first."
));
}
// Always use server-calculated total
order.setTotalAmount(serverTotal);
// ── Stock check & update ──────────────────────────────────────────
List<Map<String, Object>> stockConflicts = new ArrayList<>();
requestConflicts.remove();
for (OrderItem item : order.getItems()) {
Long productId = item.getProductId();
if (productId != null) {
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
if (updatedRows == 0) {
Product p = productRepository.findById(productId).orElse(null);
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
Map<String, Object> conflict = new HashMap<>();
conflict.put("productId", productId);
conflict.put("productName", item.getProductName());
conflict.put("requested", item.getQuantity());
conflict.put("available", left);
stockConflicts.add(conflict);
}
}
if (order.getPaymentMethod() == null || order.getPaymentMethod().isBlank()) {
order.setPaymentMethod(isStaff() ? "CASH" : "RITZ_TOKEN");
}
if (!stockConflicts.isEmpty()) {
requestConflicts.set(stockConflicts);
try {
var result = orderPlacementService.placeOrder(order, true);
if (!result.success) {
Map<String, Object> body = new HashMap<>();
body.put("success", false);
body.put("message", result.message);
if (result.errorType != null) body.put("errorType", result.errorType);
if (result.conflicts != null) body.put("conflicts", result.conflicts);
if (result.serverTotal != null) body.put("serverTotal", result.serverTotal);
return ResponseEntity.badRequest().body(body);
}
return ResponseEntity.ok(Map.of(
"success", true,
"orderNumber", result.orderNumber,
"displayOrderId", result.displayOrderId,
"message", "Order placed successfully"
));
} catch (com.rit.canteen.sales.service.OrderPlacementService.StockConflictException e) {
requestConflicts.set(e.conflicts);
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
} catch (com.rit.canteen.sales.service.OrderPlacementService.InsufficientTokensException e) {
throw new RuntimeException("INSUFFICIENT_TOKENS");
}
// ── Complete Order Details ────────────────────────────────────────
for (OrderItem item : order.getItems()) {
item.setOrder(order);
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
item.setStallName("RIT Canteen");
}
}
LocalDateTime now = LocalDateTime.now();
order.setCreatedAt(now);
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
// ── Token payment ────────────────────────────────────────────────
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
try {
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
} catch (RuntimeException e) {
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
throw e;
}
}
Order savedOrder = orderRepository.save(order);
return ResponseEntity.ok(Map.of(
"success", true,
"orderNumber", savedOrder.getOrderNumber(),
"displayOrderId", savedOrder.getDisplayOrderId(),
"message", "Order placed successfully"
));
}
// ── CUSTOMER: own orders ──────────────────────────────────────────────

View File

@@ -0,0 +1,224 @@
package com.rit.canteen.sales.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rit.canteen.sales.model.PaymentSession;
import com.rit.canteen.sales.repository.PaymentSessionRepository;
import com.rit.canteen.sales.service.PaymentService;
import com.rit.canteen.sales.service.RazorpayService;
import io.jsonwebtoken.Claims;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
private static final Logger log = LoggerFactory.getLogger(PaymentController.class);
@Autowired
private PaymentService paymentService;
@Autowired
private RazorpayService razorpayService;
@Autowired
private PaymentSessionRepository paymentSessionRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Create a Razorpay order for either food checkout or wallet top-up.
*
* Body for wallet:
* { "purpose": "WALLET_TOPUP", "amount": 100 }
*
* Body for food order:
* { "purpose": "ORDER", "order": { userId, items, totalAmount, orderType } }
*/
@PostMapping("/create")
public ResponseEntity<?> createPayment(@RequestBody Map<String, Object> body) {
try {
Long userId = requireUserId();
String purpose = body.get("purpose") != null ? body.get("purpose").toString().toUpperCase() : "";
if ("WALLET_TOPUP".equals(purpose)) {
if (body.get("amount") == null) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "amount is required"));
}
BigDecimal amount = new BigDecimal(body.get("amount").toString());
Map<String, Object> result = paymentService.createWalletTopupSession(userId, amount);
return ResponseEntity.ok(result);
}
if ("ORDER".equals(purpose)) {
@SuppressWarnings("unchecked")
Map<String, Object> order = body.get("order") instanceof Map
? (Map<String, Object>) body.get("order")
: body; // allow flat body for convenience
// Force userId from JWT — never trust client for identity
order.put("userId", userId);
Map<String, Object> result = paymentService.createOrderPaymentSession(userId, order);
if (Boolean.FALSE.equals(result.get("success"))) {
return ResponseEntity.badRequest().body(result);
}
return ResponseEntity.ok(result);
}
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "purpose must be ORDER or WALLET_TOPUP"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
} catch (IllegalStateException e) {
return ResponseEntity.status(503).body(Map.of("success", false, "message", e.getMessage()));
} catch (SecurityException e) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", e.getMessage()));
} catch (Exception e) {
log.error("Create payment failed", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", e.getMessage() != null ? e.getMessage() : "Failed to create payment"
));
}
}
/**
* Verify Razorpay checkout response and fulfill (place order / credit wallet).
* Only after this succeeds should the client show the order QR.
*/
@PostMapping("/verify")
public ResponseEntity<?> verifyPayment(@RequestBody Map<String, Object> body) {
try {
Long userId = requireUserId();
String orderId = str(body.get("razorpayOrderId"), body.get("razorpay_order_id"));
String paymentId = str(body.get("razorpayPaymentId"), body.get("razorpay_payment_id"));
String signature = str(body.get("razorpaySignature"), body.get("razorpay_signature"));
Map<String, Object> result = paymentService.verifyAndFulfill(userId, orderId, paymentId, signature);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
} catch (SecurityException e) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", e.getMessage()));
} catch (IllegalStateException e) {
return ResponseEntity.status(409).body(Map.of("success", false, "message", e.getMessage()));
} catch (Exception e) {
log.error("Verify payment failed", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", e.getMessage() != null ? e.getMessage() : "Payment verification failed"
));
}
}
/**
* Public webhook endpoint. Configure in Razorpay Dashboard:
* URL: https://<your-host>/api/payments/webhook
* Events: payment.captured
* Set RAZORPAY_WEBHOOK_SECRET to the dashboard secret.
*/
@PostMapping("/webhook")
public ResponseEntity<?> webhook(
@RequestBody String rawBody,
@RequestHeader(value = "X-Razorpay-Signature", required = false) String signature) {
try {
if (razorpayService.hasWebhookSecret()) {
if (signature == null || !razorpayService.verifyWebhookSignature(rawBody, signature)) {
log.warn("Rejected Razorpay webhook with invalid signature");
return ResponseEntity.status(400).body(Map.of("error", "Invalid signature"));
}
} else {
log.warn("Webhook received but RAZORPAY_WEBHOOK_SECRET is not set — processing cautiously");
}
JsonNode root = objectMapper.readTree(rawBody);
String event = root.path("event").asText("");
if ("payment.captured".equals(event) || "payment.authorized".equals(event)) {
JsonNode entity = root.path("payload").path("payment").path("entity");
String paymentId = entity.path("id").asText(null);
String orderId = entity.path("order_id").asText(null);
if (paymentId != null && orderId != null) {
paymentService.handlePaymentCapturedWebhook(orderId, paymentId);
}
}
// Always 200 so Razorpay does not retry endlessly on unknown events
return ResponseEntity.ok(Map.of("received", true));
} catch (Exception e) {
log.error("Webhook processing error", e);
// Return 200 to avoid noisy retries for parse errors; log for ops
return ResponseEntity.ok(Map.of("received", true, "error", e.getMessage()));
}
}
/**
* Returns the payment history for the authenticated user.
*/
@GetMapping("/history")
public ResponseEntity<?> paymentHistory() {
try {
Long userId = requireUserId();
List<PaymentSession> sessions = paymentSessionRepository.findByUserIdOrderByCreatedAtDesc(userId);
List<Map<String, Object>> list = sessions.stream().map(s -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", s.getId());
m.put("purpose", s.getPurpose().name());
m.put("status", s.getStatus().name());
m.put("amount", s.getAmountInr());
m.put("razorpayOrderId", s.getRazorpayOrderId());
m.put("razorpayPaymentId", s.getRazorpayPaymentId());
m.put("orderNumber", s.getFulfillmentOrderNumber());
m.put("createdAt", s.getCreatedAt());
m.put("fulfilledAt", s.getFulfilledAt());
m.put("failureReason", s.getFailureReason());
return m;
}).toList();
return ResponseEntity.ok(list);
} catch (SecurityException e) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", e.getMessage()));
} catch (Exception e) {
log.error("Payment history failed", e);
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Failed to fetch payment history"));
}
}
/** Health/config check for the ordering app (does not expose secret). */
@GetMapping("/config")
public ResponseEntity<?> config() {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("enabled", razorpayService.isConfigured());
if (razorpayService.isConfigured()) {
resp.put("keyId", razorpayService.getKeyId());
}
return ResponseEntity.ok(resp);
}
private Long requireUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getDetails() instanceof Claims claims) {
Object uid = claims.get("userId");
if (uid != null) {
return uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
}
}
throw new SecurityException("Authentication required");
}
private static String str(Object primary, Object fallback) {
if (primary != null && !primary.toString().isBlank()) return primary.toString();
if (fallback != null && !fallback.toString().isBlank()) return fallback.toString();
return null;
}
}

View File

@@ -126,6 +126,40 @@ public class UserController {
}
}
@PostMapping("/check-email")
public ResponseEntity<?> checkEmailExists(@RequestBody Map<String, String> body) {
String email = body.get("email");
if (email == null || email.trim().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));
}
boolean exists = userService.existsByEmail(email);
return ResponseEntity.ok(Map.of("exists", exists));
}
@PostMapping("/firebase-login")
public ResponseEntity<?> firebaseLogin(@RequestBody Map<String, String> body) {
String email = body.get("email");
String name = body.get("name");
String mobileNumber = body.get("mobileNumber"); // optional, supplied on first login
if (email == null || email.trim().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));
}
LoginResponse response = userService.firebaseLoginOrCreate(email, name, mobileNumber);
if (response.isSuccess()) {
Long userId = response.getUser() != null ? response.getUser().getId() : null;
String userMobile = response.getUser() != null ? response.getUser().getMobileNumber() : "";
if (userId != null) {
String token = jwtUtil.generateUserToken(userId, userMobile);
response.setToken(token);
}
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
}
}
@PostMapping("/logout")
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
LoginResponse response = userService.logout(request.getMobileNumber());

View File

@@ -70,9 +70,21 @@ public class WalletController {
return ResponseEntity.ok(userList);
}
/**
* Manual / admin wallet credit.
* Customers MUST use Razorpay via POST /api/payments/create (purpose=WALLET_TOPUP).
* Free top-up is restricted to staff/manager/master only.
*/
@PostMapping("/topup")
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
try {
if (!isStaff()) {
return ResponseEntity.status(403).body(Map.of(
"success", false,
"error", "Customer wallet top-up requires online payment. Use the Top Up screen (Razorpay)."
));
}
Long userId = Long.valueOf(request.get("userId").toString());
if (!canAccessUser(userId)) {
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
@@ -88,7 +100,7 @@ public class WalletController {
Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"));
}
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
String ref = request.getOrDefault("referenceId", "TOPUP-STAFF-" + System.currentTimeMillis()).toString();
User updatedUser = tokenService.topUp(userId, amount, ref);
return ResponseEntity.ok(Map.of(
"success", true,
@@ -100,6 +112,19 @@ public class WalletController {
}
}
private boolean isStaff() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return false;
if (auth.getDetails() instanceof Claims claims) {
String role = (String) claims.get("role");
return "MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role);
}
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_MASTER")
|| a.getAuthority().equals("ROLE_MANAGER")
|| a.getAuthority().equals("ROLE_STAFF"));
}
@GetMapping("/transactions/all")
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
return ResponseEntity.ok(tokenService.getAllTransactions());

View File

@@ -0,0 +1,148 @@
package com.rit.canteen.sales.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "developer_api_keys")
public class DeveloperApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String apiKey;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private String userType; // "SYSTEM" or "CUSTOMER"
@Column(nullable = false)
private String ownerIdentifier; // Email or Mobile Number
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false, columnDefinition = "boolean default false")
private boolean writeAccess = false;
@Column(nullable = true, length = 1000)
private String permissions; // comma-separated scopes (e.g. READ_PRODUCTS,WRITE_PRODUCTS)
@Column(nullable = false)
private LocalDateTime createdAt;
private LocalDateTime lastUsedAt;
@Column(nullable = true)
private Long appId;
public DeveloperApiKey() {}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUserType() {
return userType;
}
public void setUserType(String userType) {
this.userType = userType;
}
public String getOwnerIdentifier() {
return ownerIdentifier;
}
public void setOwnerIdentifier(String ownerIdentifier) {
this.ownerIdentifier = ownerIdentifier;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getLastUsedAt() {
return lastUsedAt;
}
public void setLastUsedAt(LocalDateTime lastUsedAt) {
this.lastUsedAt = lastUsedAt;
}
public boolean isWriteAccess() {
return writeAccess;
}
public void setWriteAccess(boolean writeAccess) {
this.writeAccess = writeAccess;
}
public String getPermissions() {
return permissions;
}
public void setPermissions(String permissions) {
this.permissions = permissions;
}
public Long getAppId() {
return appId;
}
public void setAppId(Long appId) {
this.appId = appId;
}
}

View File

@@ -0,0 +1,138 @@
package com.rit.canteen.sales.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "developer_api_logs")
public class DeveloperApiLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String endpoint;
@Column(nullable = false)
private String method;
@Column(nullable = true)
private String apiKey;
@Column(nullable = true)
private Long appId;
@Column(nullable = true)
private Long userId;
@Column(nullable = true)
private String userType;
@Column(nullable = false)
private int status;
@Column(nullable = true)
private String clientIp;
@Column(nullable = false)
private LocalDateTime timestamp;
@Column(columnDefinition = "TEXT")
private String responseBody;
public DeveloperApiLog() {}
@PrePersist
protected void onCreate() {
timestamp = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public Long getAppId() {
return appId;
}
public void setAppId(Long appId) {
this.appId = appId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUserType() {
return userType;
}
public void setUserType(String userType) {
this.userType = userType;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public String getClientIp() {
return clientIp;
}
public void setClientIp(String clientIp) {
this.clientIp = clientIp;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
public String getResponseBody() {
return responseBody;
}
public void setResponseBody(String responseBody) {
this.responseBody = responseBody;
}
}

View File

@@ -0,0 +1,94 @@
package com.rit.canteen.sales.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "developer_apps")
public class DeveloperApp {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private String userType; // "CUSTOMER"
@Column(nullable = false)
private String ownerIdentifier; // Email or Mobile Number
@Column(nullable = false)
private LocalDateTime createdAt;
public DeveloperApp() {}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUserType() {
return userType;
}
public void setUserType(String userType) {
this.userType = userType;
}
public String getOwnerIdentifier() {
return ownerIdentifier;
}
public void setOwnerIdentifier(String ownerIdentifier) {
this.ownerIdentifier = ownerIdentifier;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -49,6 +49,7 @@ public class LoginResponse {
private boolean isLoggedIn;
private boolean isSuspended;
private java.math.BigDecimal ritzTokenBalance;
private String membership;
public UserDto() {}
@@ -61,6 +62,16 @@ public class LoginResponse {
this.ritzTokenBalance = ritzTokenBalance;
}
public UserDto(Long id, String mobileNumber, String name, boolean isLoggedIn, boolean isSuspended, java.math.BigDecimal ritzTokenBalance, String membership) {
this.id = id;
this.mobileNumber = mobileNumber;
this.name = name;
this.isLoggedIn = isLoggedIn;
this.isSuspended = isSuspended;
this.ritzTokenBalance = ritzTokenBalance;
this.membership = membership;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@@ -74,9 +85,12 @@ public class LoginResponse {
public void setLoggedIn(boolean loggedIn) { this.isLoggedIn = loggedIn; }
public boolean isSuspended() { return isSuspended; }
public void setSuspended(boolean suspended) { isSuspended = suspended; }
public void setSuspended(boolean suspended) { this.isSuspended = suspended; }
public java.math.BigDecimal getRitzTokenBalance() { return ritzTokenBalance; }
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
public String getMembership() { return membership; }
public void setMembership(String membership) { this.membership = membership; }
}
}

View File

@@ -0,0 +1,147 @@
package com.rit.canteen.sales.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* Tracks a Razorpay payment from creation through fulfillment.
* Ensures order placement / wallet credit only happens after verified payment,
* and is idempotent across client retries and webhooks.
*/
@Entity
@Table(name = "payment_sessions", indexes = {
@Index(name = "idx_payment_sessions_user", columnList = "user_id"),
@Index(name = "idx_payment_sessions_status", columnList = "status")
})
public class PaymentSession {
public enum Purpose {
ORDER,
WALLET_TOPUP
}
public enum Status {
CREATED,
PAID,
FULFILLED,
FULFILLED_AS_CREDIT,
FAILED,
EXPIRED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private Purpose purpose;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private Status status = Status.CREATED;
/** Amount in INR (rupees), not paise */
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal amountInr;
@Column(nullable = false, length = 8)
private String currency = "INR";
@Column(name = "razorpay_order_id", nullable = false, unique = true, length = 64)
private String razorpayOrderId;
@Column(name = "razorpay_payment_id", unique = true, length = 64)
private String razorpayPaymentId;
@Column(name = "razorpay_signature", length = 256)
private String razorpaySignature;
/** Serialized cart/order payload for ORDER purpose (JSON text) */
@Column(name = "order_payload", columnDefinition = "TEXT")
private String orderPayload;
@Column(name = "fulfillment_order_number", length = 64)
private String fulfillmentOrderNumber;
@Column(name = "fulfillment_display_id", length = 32)
private String fulfillmentDisplayId;
@Column(name = "failure_reason", length = 512)
private String failureReason;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "fulfilled_at")
private LocalDateTime fulfilledAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (status == null) status = Status.CREATED;
if (currency == null) currency = "INR";
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Purpose getPurpose() { return purpose; }
public void setPurpose(Purpose purpose) { this.purpose = purpose; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public BigDecimal getAmountInr() { return amountInr; }
public void setAmountInr(BigDecimal amountInr) { this.amountInr = amountInr; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getRazorpayOrderId() { return razorpayOrderId; }
public void setRazorpayOrderId(String razorpayOrderId) { this.razorpayOrderId = razorpayOrderId; }
public String getRazorpayPaymentId() { return razorpayPaymentId; }
public void setRazorpayPaymentId(String razorpayPaymentId) { this.razorpayPaymentId = razorpayPaymentId; }
public String getRazorpaySignature() { return razorpaySignature; }
public void setRazorpaySignature(String razorpaySignature) { this.razorpaySignature = razorpaySignature; }
public String getOrderPayload() { return orderPayload; }
public void setOrderPayload(String orderPayload) { this.orderPayload = orderPayload; }
public String getFulfillmentOrderNumber() { return fulfillmentOrderNumber; }
public void setFulfillmentOrderNumber(String fulfillmentOrderNumber) { this.fulfillmentOrderNumber = fulfillmentOrderNumber; }
public String getFulfillmentDisplayId() { return fulfillmentDisplayId; }
public void setFulfillmentDisplayId(String fulfillmentDisplayId) { this.fulfillmentDisplayId = fulfillmentDisplayId; }
public String getFailureReason() { return failureReason; }
public void setFailureReason(String failureReason) { this.failureReason = failureReason; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getFulfilledAt() { return fulfilledAt; }
public void setFulfilledAt(LocalDateTime fulfilledAt) { this.fulfilledAt = fulfilledAt; }
}

View File

@@ -40,6 +40,12 @@ public class User {
@Column(nullable = false, columnDefinition = "boolean default false")
private boolean isSuspended = false;
@Column(unique = true, nullable = true)
private String email;
@Column(nullable = true)
private String membership;
@Column(nullable = true)
private LocalDateTime lastLoginAt;
@@ -75,6 +81,12 @@ public class User {
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getMembership() { return membership; }
public void setMembership(String membership) { this.membership = membership; }
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();

View File

@@ -0,0 +1,14 @@
package com.rit.canteen.sales.repository;
import com.rit.canteen.sales.model.DeveloperApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface DeveloperApiKeyRepository extends JpaRepository<DeveloperApiKey, Long> {
Optional<DeveloperApiKey> findByApiKey(String apiKey);
List<DeveloperApiKey> findByUserIdAndUserType(Long userId, String userType);
long countByUserIdAndUserType(Long userId, String userType);
List<DeveloperApiKey> findByAppId(Long appId);
void deleteByAppId(Long appId);
}

View File

@@ -0,0 +1,10 @@
package com.rit.canteen.sales.repository;
import com.rit.canteen.sales.model.DeveloperApiLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DeveloperApiLogRepository extends JpaRepository<DeveloperApiLog, Long> {
Page<DeveloperApiLog> findByUserIdAndUserType(Long userId, String userType, Pageable pageable);
}

View File

@@ -0,0 +1,9 @@
package com.rit.canteen.sales.repository;
import com.rit.canteen.sales.model.DeveloperApp;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface DeveloperAppRepository extends JpaRepository<DeveloperApp, Long> {
List<DeveloperApp> findByUserIdAndUserType(Long userId, String userType);
}

View File

@@ -0,0 +1,16 @@
package com.rit.canteen.sales.repository;
import com.rit.canteen.sales.model.PaymentSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface PaymentSessionRepository extends JpaRepository<PaymentSession, Long> {
Optional<PaymentSession> findByRazorpayOrderId(String razorpayOrderId);
Optional<PaymentSession> findByRazorpayPaymentId(String razorpayPaymentId);
boolean existsByRazorpayPaymentId(String razorpayPaymentId);
List<PaymentSession> findByUserIdOrderByCreatedAtDesc(Long userId);
}

View File

@@ -9,12 +9,16 @@ import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
List<TokenTransaction> findAllByOrderByTimestampDesc();
Optional<TokenTransaction> findByReferenceId(String referenceId);
boolean existsByReferenceId(String referenceId);
@Query("SELECT SUM(t.amount) FROM TokenTransaction t WHERE t.type = :type")
BigDecimal sumByType(@Param("type") TokenTransaction.TransactionType type);

View File

@@ -11,6 +11,8 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByMobileNumber(String mobileNumber);
boolean existsByMobileNumber(String mobileNumber);
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")

View File

@@ -2,15 +2,22 @@ package com.rit.canteen.sales.service;
import com.rit.canteen.sales.model.BaseItem;
import com.rit.canteen.sales.model.Product;
import com.rit.canteen.sales.model.Stall;
import com.rit.canteen.sales.model.SystemUser;
import com.rit.canteen.sales.repository.BaseItemRepository;
import com.rit.canteen.sales.repository.ProductRepository;
import com.rit.canteen.sales.repository.StallRepository;
import com.rit.canteen.sales.repository.SystemUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Component
public class DatabaseSeeder implements CommandLineRunner {
@@ -21,9 +28,24 @@ public class DatabaseSeeder implements CommandLineRunner {
@Autowired
private ProductRepository productRepository;
@Autowired
private StallRepository stallRepository;
@Autowired
private SystemUserRepository systemUserRepository;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Autowired
private JdbcTemplate jdbcTemplate;
@Value("${app.master.username:admin}")
private String masterUsername;
@Value("${app.master.password:admin}")
private String masterPassword;
@Override
public void run(String... args) throws Exception {
repairStallsSchema();
@@ -34,6 +56,8 @@ public class DatabaseSeeder implements CommandLineRunner {
repairLobColumns();
seedCategories();
seedProducts();
seedStalls();
seedMasterUser();
}
private void repairFeedbackSchema() {
@@ -254,4 +278,45 @@ public class DatabaseSeeder implements CommandLineRunner {
p.setActive(true);
return p;
}
private void seedStalls() {
if (stallRepository.count() == 0) {
List<Product> allProducts = productRepository.findAll();
Stall s1 = new Stall("Main Canteen Stall", "Primary food counter serving meals, fast food, and beverages", null);
Stall s2 = new Stall("Bakery & Juice Counter", "Fresh bakery items, pastries, snacks, and fresh fruit juices", null);
Stall s3 = new Stall("South Indian Express", "Authentic dosas, idlis, vadas, and South Indian breakfast specials", null);
s1.setProducts(allProducts);
s2.setProducts(allProducts.stream()
.filter(p -> "Bakery & Sweets".equalsIgnoreCase(p.getCategory()) || "Beverages & Drinks".equalsIgnoreCase(p.getCategory()) || "Snacks & Quick Bites".equalsIgnoreCase(p.getCategory()))
.toList());
s3.setProducts(allProducts.stream()
.filter(p -> "Snacks & Quick Bites".equalsIgnoreCase(p.getCategory()) || "Indian Main Course".equalsIgnoreCase(p.getCategory()))
.toList());
stallRepository.saveAll(List.of(s1, s2, s3));
System.out.println(">>> SEEDED DEFAULT STALLS (Main Canteen, Bakery & Juice Counter, South Indian Express)");
}
}
private void seedMasterUser() {
String targetUsername = (masterUsername != null && !masterUsername.isBlank()) ? masterUsername : "admin";
String targetPassword = (masterPassword != null && !masterPassword.isBlank()) ? masterPassword : "admin";
Optional<SystemUser> existingAdmin = systemUserRepository.findByEmail(targetUsername);
if (existingAdmin.isEmpty()) {
SystemUser admin = new SystemUser();
admin.setName("Admin Master");
admin.setEmail(targetUsername);
admin.setPassword(passwordEncoder.encode(targetPassword));
admin.setRole("MASTER");
admin.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
admin.setViewOnly(false);
systemUserRepository.save(admin);
System.out.println(">>> SEEDED ADMIN MASTER USER (username: " + targetUsername + ")");
} else {
System.out.println(">>> ADMIN MASTER USER already exists. Skipping overwrite.");
}
}
}

View File

@@ -0,0 +1,83 @@
package com.rit.canteen.sales.service;
import com.rit.canteen.sales.model.DeveloperApiKey;
import com.rit.canteen.sales.repository.DeveloperApiKeyRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class DeveloperApiKeyService {
@Autowired
private DeveloperApiKeyRepository repository;
@Transactional(readOnly = true)
public List<DeveloperApiKey> getKeysForUser(Long userId, String userType) {
return repository.findByUserIdAndUserType(userId, userType);
}
@Transactional
public DeveloperApiKey createKey(Long userId, String userType, String ownerIdentifier, String name, boolean writeAccess, String permissions) {
return createKey(userId, userType, ownerIdentifier, name, writeAccess, permissions, null);
}
@Transactional
public DeveloperApiKey createKey(Long userId, String userType, String ownerIdentifier, String name, boolean writeAccess, String permissions, Long appId) {
// Enforce the 3 key limit only for regular CUSTOMER users. SYSTEM users (admin/managers) get unlimited keys.
if (!"SYSTEM".equalsIgnoreCase(userType)) {
long count = repository.countByUserIdAndUserType(userId, userType);
if (count >= 3) {
throw new IllegalStateException("Maximum limit of 3 API keys reached");
}
if (appId == null) {
throw new IllegalStateException("App ID is required for Customer keys");
}
}
DeveloperApiKey key = new DeveloperApiKey();
key.setUserId(userId);
key.setUserType(userType);
key.setOwnerIdentifier(ownerIdentifier);
key.setName(name);
key.setWriteAccess(writeAccess);
key.setPermissions(permissions);
key.setAppId(appId);
// Generate a secure API key
String prefix = writeAccess ? "DEV-W-" : "DEV-";
String rawKey = prefix + UUID.randomUUID().toString().replace("-", "").toUpperCase();
key.setApiKey(rawKey);
return repository.save(key);
}
@Transactional
public void deleteKey(Long keyId, Long userId, String userType) {
Optional<DeveloperApiKey> keyOpt = repository.findById(keyId);
if (keyOpt.isPresent()) {
DeveloperApiKey key = keyOpt.get();
if (key.getUserId().equals(userId) && key.getUserType().equals(userType)) {
repository.delete(key);
} else {
throw new SecurityException("Unauthorized to delete this API key");
}
}
}
@Transactional
public Optional<DeveloperApiKey> validateAndUseKey(String apiKey) {
Optional<DeveloperApiKey> keyOpt = repository.findByApiKey(apiKey);
if (keyOpt.isPresent() && keyOpt.get().isActive()) {
DeveloperApiKey key = keyOpt.get();
key.setLastUsedAt(LocalDateTime.now());
return Optional.of(repository.save(key));
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,259 @@
package com.rit.canteen.sales.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rit.canteen.sales.model.Order;
import com.rit.canteen.sales.model.OrderItem;
import com.rit.canteen.sales.model.Product;
import com.rit.canteen.sales.repository.OrderRepository;
import com.rit.canteen.sales.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
/**
* Shared order placement used by direct Ritz-token checkout and post-payment Razorpay fulfillment.
*/
@Service
public class OrderPlacementService {
public static class PlacementResult {
public final boolean success;
public final String orderNumber;
public final String displayOrderId;
public final String message;
public final String errorType;
public final List<Map<String, Object>> conflicts;
public final BigDecimal serverTotal;
private PlacementResult(boolean success, String orderNumber, String displayOrderId,
String message, String errorType, List<Map<String, Object>> conflicts,
BigDecimal serverTotal) {
this.success = success;
this.orderNumber = orderNumber;
this.displayOrderId = displayOrderId;
this.message = message;
this.errorType = errorType;
this.conflicts = conflicts;
this.serverTotal = serverTotal;
}
public static PlacementResult ok(String orderNumber, String displayOrderId) {
return new PlacementResult(true, orderNumber, displayOrderId, "Order placed successfully", null, null, null);
}
public static PlacementResult fail(String message, String errorType, List<Map<String, Object>> conflicts) {
return new PlacementResult(false, null, null, message, errorType, conflicts, null);
}
public static PlacementResult priceMismatch(BigDecimal serverTotal, BigDecimal clientTotal) {
return new PlacementResult(false, null, null,
"Price mismatch detected. Please refresh and try again.",
"PRICE_ERROR", null, serverTotal);
}
}
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private TokenService tokenService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Validates items and returns the server-side total. Does not mutate stock.
*/
public BigDecimal calculateServerTotal(List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
BigDecimal serverTotal = BigDecimal.ZERO;
for (OrderItem item : items) {
if (item.getProductId() == null) {
throw new IllegalArgumentException("Each item must have a productId");
}
Product product = productRepository.findById(item.getProductId())
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + item.getProductId()));
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
? product.getOfferPrice() : product.getPrice();
if (item.getProductName() != null && item.getProductName().endsWith(" (Parcel)") && product.isParcellable()) {
unitPrice = unitPrice.add(BigDecimal.valueOf(5));
}
item.setPrice(unitPrice);
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
}
return serverTotal;
}
/**
* Soft stock availability check (no decrement).
*/
public List<Map<String, Object>> checkStock(List<OrderItem> items) {
List<Map<String, Object>> conflicts = new ArrayList<>();
for (OrderItem item : items) {
if (item.getProductId() == null) continue;
Product p = productRepository.findById(item.getProductId()).orElse(null);
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
if (left < item.getQuantity()) {
Map<String, Object> conflict = new HashMap<>();
conflict.put("productId", item.getProductId());
conflict.put("productName", item.getProductName());
conflict.put("requested", item.getQuantity());
conflict.put("available", left);
conflicts.add(conflict);
}
}
return conflicts;
}
/**
* Places an order after payment authorization (Ritz tokens or verified Razorpay).
*
* Uses REQUIRES_NEW so a stock failure can roll back only the order attempt,
* allowing the payment flow to credit the wallet as a fallback without
* UnexpectedRollbackException on the outer transaction.
*
* @param deductRitzTokens if true, spends wallet tokens; if false, assumes external payment already captured
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PlacementResult placeOrder(Order order, boolean deductRitzTokens) {
if (order.getItems() == null || order.getItems().isEmpty()) {
return PlacementResult.fail("Order must have items", "VALIDATION_ERROR", null);
}
BigDecimal serverTotal;
try {
serverTotal = calculateServerTotal(order.getItems());
} catch (IllegalArgumentException e) {
return PlacementResult.fail(e.getMessage(), "VALIDATION_ERROR", null);
}
if (order.getTotalAmount() != null
&& serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
return PlacementResult.priceMismatch(serverTotal, order.getTotalAmount());
}
order.setTotalAmount(serverTotal);
// Stock decrement
List<Map<String, Object>> stockConflicts = new ArrayList<>();
for (OrderItem item : order.getItems()) {
Long productId = item.getProductId();
if (productId != null) {
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
if (updatedRows == 0) {
Product p = productRepository.findById(productId).orElse(null);
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
Map<String, Object> conflict = new HashMap<>();
conflict.put("productId", productId);
conflict.put("productName", item.getProductName());
conflict.put("requested", item.getQuantity());
conflict.put("available", left);
stockConflicts.add(conflict);
}
}
}
if (!stockConflicts.isEmpty()) {
// Rollback transaction via exception so stock decrements reverse
throw new StockConflictException(stockConflicts);
}
for (OrderItem item : order.getItems()) {
item.setOrder(order);
if (item.getStallName() == null || item.getStallName().isEmpty() || "Unknown Stall".equals(item.getStallName())) {
item.setStallName("RIT Canteen");
}
}
LocalDateTime now = LocalDateTime.now();
order.setCreatedAt(now);
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
if (deductRitzTokens && "RITZ_TOKEN".equals(order.getPaymentMethod())) {
try {
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
} catch (RuntimeException e) {
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
throw new InsufficientTokensException();
}
throw e;
}
}
Order saved = orderRepository.save(order);
return PlacementResult.ok(saved.getOrderNumber(), saved.getDisplayOrderId());
}
public Order buildOrderFromPayload(String json, Long userId, String paymentMethod) {
try {
Map<String, Object> map = objectMapper.readValue(json, new TypeReference<>() {});
Order order = new Order();
order.setUserId(userId);
order.setPaymentMethod(paymentMethod);
order.setOrderType(map.get("orderType") != null ? map.get("orderType").toString() : "MY_ORDER");
if (map.get("totalAmount") != null) {
order.setTotalAmount(new BigDecimal(map.get("totalAmount").toString()));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> itemsRaw = (List<Map<String, Object>>) map.get("items");
List<OrderItem> items = new ArrayList<>();
if (itemsRaw != null) {
for (Map<String, Object> ir : itemsRaw) {
OrderItem item = new OrderItem();
if (ir.get("productId") != null) {
item.setProductId(Long.valueOf(ir.get("productId").toString()));
}
item.setProductName(ir.get("productName") != null ? ir.get("productName").toString() : null);
if (ir.get("price") != null) {
item.setPrice(new BigDecimal(ir.get("price").toString()));
}
item.setQuantity(ir.get("quantity") != null ? Integer.parseInt(ir.get("quantity").toString()) : 1);
if (ir.get("stallId") != null && !"null".equals(String.valueOf(ir.get("stallId")))) {
item.setStallId(Long.valueOf(ir.get("stallId").toString()));
}
item.setStallName(ir.get("stallName") != null ? ir.get("stallName").toString() : null);
items.add(item);
}
}
order.setItems(items);
return order;
} catch (Exception e) {
throw new IllegalArgumentException("Invalid order payload: " + e.getMessage());
}
}
public String serializeOrderPayload(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (Exception e) {
throw new IllegalArgumentException("Could not serialize order payload");
}
}
public static class StockConflictException extends RuntimeException {
public final List<Map<String, Object>> conflicts;
public StockConflictException(List<Map<String, Object>> conflicts) {
super("CONCURRENCY_STOCK_FAILURE");
this.conflicts = conflicts;
}
}
public static class InsufficientTokensException extends RuntimeException {
public InsufficientTokensException() {
super("INSUFFICIENT_TOKENS");
}
}
}

View File

@@ -0,0 +1,411 @@
package com.rit.canteen.sales.service;
import com.razorpay.Order;
import com.razorpay.Payment;
import com.rit.canteen.sales.model.OrderItem;
import com.rit.canteen.sales.model.PaymentSession;
import com.rit.canteen.sales.model.User;
import com.rit.canteen.sales.repository.PaymentSessionRepository;
import com.rit.canteen.sales.repository.TokenTransactionRepository;
import com.rit.canteen.sales.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
private static final BigDecimal MIN_TOPUP = new BigDecimal("50");
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
@Autowired
private RazorpayService razorpayService;
@Autowired
private PaymentSessionRepository paymentSessionRepository;
@Autowired
private OrderPlacementService orderPlacementService;
@Autowired
private TokenService tokenService;
@Autowired
private TokenTransactionRepository tokenTransactionRepository;
@Autowired
private UserRepository userRepository;
public Map<String, Object> createWalletTopupSession(Long userId, BigDecimal amountInr) throws Exception {
ensureConfigured();
if (amountInr == null || amountInr.compareTo(MIN_TOPUP) < 0) {
throw new IllegalArgumentException("Minimum top up amount is ₹50");
}
if (amountInr.compareTo(MAX_TOPUP) > 0) {
throw new IllegalArgumentException("Maximum top up per transaction is ₹5,000");
}
// Normalize to 2 decimals
amountInr = amountInr.setScale(2, RoundingMode.HALF_UP);
if (amountInr.stripTrailingZeros().scale() > 0) {
// Ritz tokens are whole units
throw new IllegalArgumentException("Top up amount must be a whole number of rupees");
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
String receipt = "TOP-" + userId + "-" + System.currentTimeMillis();
Map<String, String> notes = Map.of(
"purpose", "WALLET_TOPUP",
"userId", String.valueOf(userId)
);
Order rzOrder = razorpayService.createOrder(amountInr, receipt, notes);
PaymentSession session = new PaymentSession();
session.setUserId(userId);
session.setPurpose(PaymentSession.Purpose.WALLET_TOPUP);
session.setStatus(PaymentSession.Status.CREATED);
session.setAmountInr(amountInr);
session.setCurrency("INR");
session.setRazorpayOrderId(rzOrder.get("id"));
session.setCreatedAt(LocalDateTime.now());
session.setUpdatedAt(LocalDateTime.now());
paymentSessionRepository.save(session);
return checkoutPayload(session, user);
}
@SuppressWarnings("unchecked")
public Map<String, Object> createOrderPaymentSession(Long userId, Map<String, Object> orderRequest) throws Exception {
ensureConfigured();
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// Build temporary order for validation
String payloadJson = orderPlacementService.serializeOrderPayload(orderRequest);
com.rit.canteen.sales.model.Order draft =
orderPlacementService.buildOrderFromPayload(payloadJson, userId, "RAZORPAY");
BigDecimal serverTotal = orderPlacementService.calculateServerTotal(draft.getItems());
if (serverTotal.compareTo(BigDecimal.ONE) < 0) {
throw new IllegalArgumentException("Order total must be at least ₹1");
}
List<Map<String, Object>> stockConflicts = orderPlacementService.checkStock(draft.getItems());
if (!stockConflicts.isEmpty()) {
Map<String, Object> err = new LinkedHashMap<>();
err.put("success", false);
err.put("errorType", "STOCK_ERROR");
err.put("message", "Some items are no longer available in the requested quantity.");
err.put("conflicts", stockConflicts);
return err;
}
// Persist validated total + items into payload
orderRequest.put("totalAmount", serverTotal);
List<Map<String, Object>> normalizedItems = new ArrayList<>();
for (OrderItem item : draft.getItems()) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("productId", item.getProductId());
m.put("productName", item.getProductName());
m.put("price", item.getPrice());
m.put("quantity", item.getQuantity());
m.put("stallId", item.getStallId());
m.put("stallName", item.getStallName());
normalizedItems.add(m);
}
orderRequest.put("items", normalizedItems);
orderRequest.put("orderType", orderRequest.getOrDefault("orderType", "MY_ORDER"));
payloadJson = orderPlacementService.serializeOrderPayload(orderRequest);
String receipt = "ORD-" + userId + "-" + System.currentTimeMillis();
Map<String, String> notes = Map.of(
"purpose", "ORDER",
"userId", String.valueOf(userId)
);
Order rzOrder = razorpayService.createOrder(serverTotal, receipt, notes);
PaymentSession session = new PaymentSession();
session.setUserId(userId);
session.setPurpose(PaymentSession.Purpose.ORDER);
session.setStatus(PaymentSession.Status.CREATED);
session.setAmountInr(serverTotal);
session.setCurrency("INR");
session.setRazorpayOrderId(rzOrder.get("id"));
session.setOrderPayload(payloadJson);
session.setCreatedAt(LocalDateTime.now());
session.setUpdatedAt(LocalDateTime.now());
paymentSessionRepository.save(session);
return checkoutPayload(session, user);
}
/**
* Verifies Razorpay checkout response and fulfills the payment session.
* Idempotent: replaying the same payment returns the previous fulfillment result.
*/
@Transactional
public Map<String, Object> verifyAndFulfill(Long userId, String razorpayOrderId,
String razorpayPaymentId, String razorpaySignature) throws Exception {
ensureConfigured();
if (razorpayOrderId == null || razorpayPaymentId == null || razorpaySignature == null) {
throw new IllegalArgumentException("Missing payment verification fields");
}
PaymentSession session = paymentSessionRepository.findByRazorpayOrderId(razorpayOrderId)
.orElseThrow(() -> new IllegalArgumentException("Unknown payment session"));
if (!session.getUserId().equals(userId)) {
throw new SecurityException("Payment session does not belong to this user");
}
// Idempotent success
if (session.getStatus() == PaymentSession.Status.FULFILLED
|| session.getStatus() == PaymentSession.Status.FULFILLED_AS_CREDIT) {
return buildFulfillmentResponse(session);
}
// Another path may have already stored this payment id
Optional<PaymentSession> byPayment = paymentSessionRepository.findByRazorpayPaymentId(razorpayPaymentId);
if (byPayment.isPresent() && !byPayment.get().getId().equals(session.getId())) {
throw new IllegalStateException("Payment already linked to another session");
}
if (!razorpayService.verifyPaymentSignature(razorpayOrderId, razorpayPaymentId, razorpaySignature)) {
session.setStatus(PaymentSession.Status.FAILED);
session.setFailureReason("Invalid payment signature");
paymentSessionRepository.save(session);
throw new SecurityException("Payment signature verification failed");
}
// Server-side confirmation with Razorpay API
Payment payment = razorpayService.fetchPayment(razorpayPaymentId);
if (!razorpayService.isPaymentCaptured(payment)) {
session.setStatus(PaymentSession.Status.FAILED);
session.setFailureReason("Payment not captured: " + payment.get("status"));
paymentSessionRepository.save(session);
throw new IllegalStateException("Payment has not been captured yet. Status: " + payment.get("status"));
}
// Ensure payment is for this order
String paymentOrderId = payment.get("order_id");
if (paymentOrderId != null && !razorpayOrderId.equals(paymentOrderId)) {
session.setStatus(PaymentSession.Status.FAILED);
session.setFailureReason("Payment order mismatch");
paymentSessionRepository.save(session);
throw new SecurityException("Payment does not match Razorpay order");
}
BigDecimal paidInr = razorpayService.paymentAmountInr(payment);
if (paidInr.compareTo(session.getAmountInr()) != 0) {
session.setStatus(PaymentSession.Status.FAILED);
session.setFailureReason("Amount mismatch: paid=" + paidInr + " expected=" + session.getAmountInr());
paymentSessionRepository.save(session);
throw new SecurityException("Paid amount does not match expected amount");
}
session.setRazorpayPaymentId(razorpayPaymentId);
session.setRazorpaySignature(razorpaySignature);
session.setStatus(PaymentSession.Status.PAID);
paymentSessionRepository.save(session);
return fulfillSession(session);
}
/**
* Webhook-driven fulfillment when client disconnects after paying.
*/
@Transactional
public void handlePaymentCapturedWebhook(String razorpayOrderId, String razorpayPaymentId) {
try {
PaymentSession session = paymentSessionRepository.findByRazorpayOrderId(razorpayOrderId).orElse(null);
if (session == null) {
log.warn("Webhook for unknown order {}", razorpayOrderId);
return;
}
if (session.getStatus() == PaymentSession.Status.FULFILLED
|| session.getStatus() == PaymentSession.Status.FULFILLED_AS_CREDIT) {
return;
}
// Verify via API
Payment payment = razorpayService.fetchPayment(razorpayPaymentId);
if (!razorpayService.isPaymentCaptured(payment)) {
log.warn("Webhook payment {} not captured", razorpayPaymentId);
return;
}
BigDecimal paidInr = razorpayService.paymentAmountInr(payment);
if (paidInr.compareTo(session.getAmountInr()) != 0) {
log.error("Webhook amount mismatch for order {}", razorpayOrderId);
session.setStatus(PaymentSession.Status.FAILED);
session.setFailureReason("Webhook amount mismatch");
paymentSessionRepository.save(session);
return;
}
session.setRazorpayPaymentId(razorpayPaymentId);
session.setStatus(PaymentSession.Status.PAID);
paymentSessionRepository.save(session);
fulfillSession(session);
} catch (Exception e) {
log.error("Webhook fulfillment failed for order {}: {}", razorpayOrderId, e.getMessage(), e);
}
}
private Map<String, Object> fulfillSession(PaymentSession session) throws Exception {
if (session.getPurpose() == PaymentSession.Purpose.WALLET_TOPUP) {
return fulfillWalletTopup(session);
}
return fulfillOrder(session);
}
private Map<String, Object> fulfillWalletTopup(PaymentSession session) {
String ref = "RZP-" + session.getRazorpayPaymentId();
if (tokenTransactionRepository.existsByReferenceId(ref)) {
session.setStatus(PaymentSession.Status.FULFILLED);
session.setFulfilledAt(LocalDateTime.now());
paymentSessionRepository.save(session);
User user = userRepository.findById(session.getUserId()).orElse(null);
Map<String, Object> resp = buildFulfillmentResponse(session);
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
return resp;
}
User updated = tokenService.topUp(session.getUserId(), session.getAmountInr(), ref);
session.setStatus(PaymentSession.Status.FULFILLED);
session.setFulfilledAt(LocalDateTime.now());
paymentSessionRepository.save(session);
Map<String, Object> resp = buildFulfillmentResponse(session);
resp.put("newBalance", updated.getRitzTokenBalance());
resp.put("message", "Successfully added " + session.getAmountInr() + " Ritz Tokens");
return resp;
}
private Map<String, Object> fulfillOrder(PaymentSession session) {
try {
com.rit.canteen.sales.model.Order order = orderPlacementService.buildOrderFromPayload(
session.getOrderPayload(), session.getUserId(), "RAZORPAY");
order.setTotalAmount(session.getAmountInr());
OrderPlacementService.PlacementResult result =
orderPlacementService.placeOrder(order, false);
if (!result.success) {
// Should not normally reach here for stock (throws), but handle validation failures
return creditAsFallback(session, result.message);
}
session.setStatus(PaymentSession.Status.FULFILLED);
session.setFulfillmentOrderNumber(result.orderNumber);
session.setFulfillmentDisplayId(result.displayOrderId);
session.setFulfilledAt(LocalDateTime.now());
paymentSessionRepository.save(session);
return buildFulfillmentResponse(session);
} catch (OrderPlacementService.StockConflictException e) {
log.warn("Stock conflict after payment {}; crediting wallet as fallback", session.getRazorpayPaymentId());
return creditAsFallback(session,
"Payment received, but some items went out of stock. ₹"
+ session.getAmountInr() + " has been credited to your Ritz wallet.");
}
}
private Map<String, Object> creditAsFallback(PaymentSession session, String reason) {
String ref = "RZP-FALLBACK-" + session.getRazorpayPaymentId();
if (!tokenTransactionRepository.existsByReferenceId(ref)) {
tokenService.topUp(session.getUserId(), session.getAmountInr(), ref);
}
session.setStatus(PaymentSession.Status.FULFILLED_AS_CREDIT);
session.setFailureReason(reason);
session.setFulfilledAt(LocalDateTime.now());
paymentSessionRepository.save(session);
User user = userRepository.findById(session.getUserId()).orElse(null);
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("success", true);
resp.put("type", "WALLET_CREDIT_FALLBACK");
resp.put("purpose", session.getPurpose().name());
resp.put("status", session.getStatus().name());
resp.put("message", reason);
resp.put("creditedAmount", session.getAmountInr());
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
resp.put("razorpayPaymentId", session.getRazorpayPaymentId());
return resp;
}
private Map<String, Object> buildFulfillmentResponse(PaymentSession session) {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("success", true);
resp.put("purpose", session.getPurpose().name());
resp.put("status", session.getStatus().name());
resp.put("razorpayOrderId", session.getRazorpayOrderId());
resp.put("razorpayPaymentId", session.getRazorpayPaymentId());
resp.put("amount", session.getAmountInr());
if (session.getPurpose() == PaymentSession.Purpose.ORDER
&& session.getStatus() == PaymentSession.Status.FULFILLED) {
resp.put("type", "ORDER");
resp.put("orderNumber", session.getFulfillmentOrderNumber());
resp.put("displayOrderId", session.getFulfillmentDisplayId());
resp.put("message", "Order placed successfully");
} else if (session.getPurpose() == PaymentSession.Purpose.WALLET_TOPUP) {
resp.put("type", "WALLET_TOPUP");
User user = userRepository.findById(session.getUserId()).orElse(null);
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
resp.put("message", "Wallet top-up successful");
} else if (session.getStatus() == PaymentSession.Status.FULFILLED_AS_CREDIT) {
resp.put("type", "WALLET_CREDIT_FALLBACK");
resp.put("message", session.getFailureReason());
User user = userRepository.findById(session.getUserId()).orElse(null);
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
}
return resp;
}
private Map<String, Object> checkoutPayload(PaymentSession session, User user) {
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("success", true);
resp.put("paymentSessionId", session.getId());
resp.put("keyId", razorpayService.getKeyId());
resp.put("razorpayOrderId", session.getRazorpayOrderId());
resp.put("amount", RazorpayService.toPaise(session.getAmountInr())); // paise for Checkout.js
resp.put("amountInr", session.getAmountInr());
resp.put("currency", session.getCurrency());
resp.put("purpose", session.getPurpose().name());
resp.put("name", "Tillo Canteen");
resp.put("description", session.getPurpose() == PaymentSession.Purpose.WALLET_TOPUP
? "Ritz Wallet Top-up"
: "Food Order Payment");
Map<String, Object> prefill = new LinkedHashMap<>();
prefill.put("name", user.getName() != null ? user.getName() : "");
prefill.put("contact", user.getMobileNumber() != null ? user.getMobileNumber() : "");
if (user.getEmail() != null) prefill.put("email", user.getEmail());
resp.put("prefill", prefill);
Map<String, String> notes = new LinkedHashMap<>();
notes.put("paymentSessionId", String.valueOf(session.getId()));
notes.put("userId", String.valueOf(user.getId()));
resp.put("notes", notes);
return resp;
}
private void ensureConfigured() {
if (!razorpayService.isConfigured()) {
throw new IllegalStateException(
"Razorpay is not configured on the server. Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.");
}
}
}

View File

@@ -0,0 +1,166 @@
package com.rit.canteen.sales.service;
import com.razorpay.Order;
import com.razorpay.Payment;
import com.razorpay.RazorpayClient;
import com.razorpay.RazorpayException;
import com.razorpay.Utils;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.Map;
/**
* Thin wrapper around the official Razorpay Java SDK.
* Secret key never leaves the server.
*/
@Service
public class RazorpayService {
private static final Logger log = LoggerFactory.getLogger(RazorpayService.class);
@Value("${razorpay.key.id:}")
private String keyId;
@Value("${razorpay.key.secret:}")
private String keySecret;
@Value("${razorpay.webhook.secret:}")
private String webhookSecret;
public boolean isConfigured() {
return keyId != null && !keyId.isBlank()
&& keySecret != null && !keySecret.isBlank();
}
public String getKeyId() {
return keyId;
}
public boolean hasWebhookSecret() {
return webhookSecret != null && !webhookSecret.isBlank();
}
private RazorpayClient client() throws RazorpayException {
if (!isConfigured()) {
throw new RazorpayException("Razorpay is not configured. Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.");
}
return new RazorpayClient(keyId, keySecret);
}
/**
* Creates a Razorpay Order for the given INR amount.
* @param amountInr amount in rupees (e.g. 120.00)
* @param receipt short unique receipt id (max 40 chars for Razorpay)
* @param notes optional notes map
*/
public Order createOrder(BigDecimal amountInr, String receipt, Map<String, String> notes) throws RazorpayException {
long amountPaise = toPaise(amountInr);
if (amountPaise < 100) {
throw new RazorpayException("Minimum payment amount is ₹1.00");
}
JSONObject options = new JSONObject();
options.put("amount", amountPaise);
options.put("currency", "INR");
options.put("receipt", receipt != null && receipt.length() > 40 ? receipt.substring(0, 40) : receipt);
options.put("payment_capture", 1); // auto-capture
if (notes != null && !notes.isEmpty()) {
JSONObject notesJson = new JSONObject();
notes.forEach(notesJson::put);
options.put("notes", notesJson);
}
Order order = client().orders.create(options);
log.info("Created Razorpay order {} for {} paise", order.get("id"), amountPaise);
return order;
}
/**
* Verifies checkout signature: HMAC_SHA256(orderId|paymentId, secret)
*/
public boolean verifyPaymentSignature(String orderId, String paymentId, String signature) {
if (orderId == null || paymentId == null || signature == null) {
return false;
}
try {
JSONObject attributes = new JSONObject();
attributes.put("razorpay_order_id", orderId);
attributes.put("razorpay_payment_id", paymentId);
attributes.put("razorpay_signature", signature);
return Utils.verifyPaymentSignature(attributes, keySecret);
} catch (Exception e) {
log.warn("Payment signature verification failed: {}", e.getMessage());
return false;
}
}
/**
* Verifies Razorpay webhook signature using X-Razorpay-Signature header.
*/
public boolean verifyWebhookSignature(String body, String signatureHeader) {
if (!hasWebhookSecret() || body == null || signatureHeader == null) {
return false;
}
try {
return Utils.verifyWebhookSignature(body, signatureHeader, webhookSecret);
} catch (Exception e) {
log.warn("Webhook signature verification failed: {}", e.getMessage());
return false;
}
}
/**
* Fetches payment from Razorpay API to double-check status/amount server-side.
*/
public Payment fetchPayment(String paymentId) throws RazorpayException {
return client().payments.fetch(paymentId);
}
public boolean isPaymentCaptured(Payment payment) {
if (payment == null) return false;
String status = payment.get("status");
return "captured".equalsIgnoreCase(status) || "authorized".equalsIgnoreCase(status);
}
public BigDecimal paymentAmountInr(Payment payment) {
if (payment == null) return BigDecimal.ZERO;
Object amountObj = payment.get("amount");
long paise;
if (amountObj instanceof Number n) {
paise = n.longValue();
} else {
paise = Long.parseLong(String.valueOf(amountObj));
}
return BigDecimal.valueOf(paise).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
public static long toPaise(BigDecimal amountInr) {
return amountInr
.setScale(2, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(0, RoundingMode.HALF_UP)
.longValueExact();
}
/** Constant-time hex HMAC for any custom checks */
public String hmacSha256Hex(String data, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException("HMAC computation failed", e);
}
}
}

View File

@@ -61,7 +61,7 @@ public class UserService {
userRepository.save(user);
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
);
return new LoginResponse(true, "Registration successful. You are now logged in.", userDto);
@@ -81,7 +81,7 @@ public class UserService {
if (user.isSuspended()) {
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
);
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.", userDto);
}
@@ -95,7 +95,7 @@ public class UserService {
userRepository.save(user);
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
);
return new LoginResponse(true, "Login successful.", userDto);
@@ -161,7 +161,7 @@ public class UserService {
return null;
}
User user = userOpt.get();
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership());
}
/**
@@ -181,7 +181,8 @@ public class UserService {
user.getName(),
user.isLoggedIn(),
user.isSuspended(),
user.getRitzTokenBalance()
user.getRitzTokenBalance(),
user.getMembership()
));
}
@@ -220,7 +221,7 @@ public class UserService {
}
userRepository.save(user);
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership());
}
/**
@@ -241,7 +242,7 @@ public class UserService {
}
userRepository.save(user);
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership());
}
/**
@@ -264,5 +265,60 @@ public class UserService {
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
}
/**
* Authenticate or create a customer user via Firebase/Google login.
*/
public boolean existsByEmail(String email) {
if (email == null) return false;
return userRepository.existsByEmail(email.trim().toLowerCase());
}
public LoginResponse firebaseLoginOrCreate(String email, String name, String mobileNumber) {
if (email == null || email.isBlank()) {
return new LoginResponse(false, "Email is required for Google authentication.");
}
String cleanEmail = email.trim().toLowerCase();
String finalName = name != null ? name : "Google User";
String membership = "Member"; // Everyone is classified as a normal Member now
Optional<User> userOpt = userRepository.findByEmail(cleanEmail);
User user;
if (userOpt.isPresent()) {
user = userOpt.get();
if (user.isSuspended()) {
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.");
}
user.setName(finalName);
user.setMembership(membership);
user.setLoggedIn(true);
user.setLastLoginAt(LocalDateTime.now());
userRepository.save(user);
} else {
if (mobileNumber == null || !mobileNumber.matches("^[0-9]{10}$")) {
return new LoginResponse(false, "A valid 10-digit mobile number is required for first-time registration.");
}
if (userRepository.existsByMobileNumber(mobileNumber)) {
return new LoginResponse(false, "This mobile number is already registered to another account.");
}
user = new User();
user.setEmail(cleanEmail);
user.setName(finalName);
user.setMembership(membership);
user.setMobileNumber(mobileNumber);
user.setPinHash(passwordEncoder.encode(java.util.UUID.randomUUID().toString()));
user.setLoggedIn(true);
user.setLastLoginAt(LocalDateTime.now());
userRepository.save(user);
}
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
);
return new LoginResponse(true, "Login successful.", userDto);
}
}

View File

@@ -6,9 +6,9 @@ server.port=8080
# DATABASE — REQUIRED environment variables
# Set these in your environment or a .env file
# ============================================================
spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy
spring.datasource.url=jdbc:postgresql://localhost:5432/rit_cms
spring.datasource.username=postgres
spring.datasource.password=sidharth
spring.datasource.password=RITHosting123
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
@@ -20,14 +20,14 @@ spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
# JWT — REQUIRED environment variables
# MUST provide a secure random key (min 256-bit)
# ============================================================
app.jwt.secret=${JWT_SECRET}
app.jwt.secret=${JWT_SECRET:default_jwt_secret_key_which_is_at_least_32_bytes_long}
app.jwt.expiration-ms=86400000
# ============================================================
# Master Account — REQUIRED environment variables
# ============================================================
app.master.username=${MASTER_USER:admin}
app.master.password=${MASTER_PASSWORD}
app.master.password=${MASTER_PASSWORD:admin}
# File upload configuration
spring.servlet.multipart.max-file-size=10MB
@@ -62,3 +62,12 @@ spring.datasource.hikari.connection-timeout=20000
logging.level.org.apache.coyote.http11.Http11InputBuffer=ERROR
logging.level.org.apache.tomcat.util.http.parser.HttpParser=ERROR
# ============================================================
# RAZORPAY — REQUIRED for online payments (ordering site + wallet top-up)
# Set via environment variables. NEVER commit live secrets to git.
# ============================================================
razorpay.key.id=${RAZORPAY_KEY_ID:rzp_live_TFJG984gtJCqrs}
razorpay.key.secret=${RAZORPAY_KEY_SECRET:LlAsZ6qzx94qgzXQu9GbQLMZ}
# Optional but strongly recommended in production (Razorpay Dashboard → Webhooks)
razorpay.webhook.secret=${RAZORPAY_WEBHOOK_SECRET:}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

14
counter-frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Counter POS</title>
<script type="module" crossorigin src="/assets/index-C3UP9F-T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8tt0cn-G.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

1
counter-frontend/node_modules/.bin/acorn generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../acorn/bin/acorn

1
counter-frontend/node_modules/.bin/autoprefixer generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../autoprefixer/bin/autoprefixer

View File

@@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.cjs

1
counter-frontend/node_modules/.bin/browserslist generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../browserslist/cli.js

1
counter-frontend/node_modules/.bin/eslint generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../eslint/bin/eslint.js

1
counter-frontend/node_modules/.bin/jiti generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../jiti/lib/jiti-cli.mjs

1
counter-frontend/node_modules/.bin/js-yaml generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../js-yaml/bin/js-yaml.js

1
counter-frontend/node_modules/.bin/jsesc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../jsesc/bin/jsesc

1
counter-frontend/node_modules/.bin/json5 generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../json5/lib/cli.js

1
counter-frontend/node_modules/.bin/nanoid generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../nanoid/bin/nanoid.cjs

1
counter-frontend/node_modules/.bin/node-which generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../which/bin/node-which

1
counter-frontend/node_modules/.bin/parser generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@babel/parser/bin/babel-parser.js

1
counter-frontend/node_modules/.bin/rolldown generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../rolldown/bin/cli.mjs

1
counter-frontend/node_modules/.bin/semver generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../semver/bin/semver.js

1
counter-frontend/node_modules/.bin/tsc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../typescript/bin/tsc

1
counter-frontend/node_modules/.bin/tsserver generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../typescript/bin/tsserver

View File

@@ -0,0 +1 @@
../update-browserslist-db/cli.js

1
counter-frontend/node_modules/.bin/vite generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../vite/bin/vite.js

2870
counter-frontend/node_modules/.package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
{
"hash": "9cdbac26",
"configHash": "315230d9",
"lockfileHash": "e081472a",
"browserHash": "6b7b94cf",
"optimized": {
"date-fns": {
"src": "../../date-fns/index.js",
"file": "date-fns.js",
"fileHash": "2ee4f16f",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "64e1429b",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "712bfbf0",
"needsInterop": false
},
"qrcode.react": {
"src": "../../qrcode.react/lib/esm/index.js",
"file": "qrcode__react.js",
"fileHash": "8f444abd",
"needsInterop": false
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "c940c610",
"needsInterop": true
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "58ab655a",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "ae3c0c04",
"needsInterop": false
},
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "92583b6b",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "ae51fb87",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "34707780",
"needsInterop": true
}
},
"chunks": {
"react-QWdP705l": {
"file": "react-QWdP705l.js",
"isDynamicEntry": false
}
}
}

12789
counter-frontend/node_modules/.vite/deps/date-fns.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

13792
counter-frontend/node_modules/.vite/deps/framer-motion.js generated vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More