Compare commits
10 Commits
8aa346ac2d
...
38453e7f6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38453e7f6d | ||
|
|
fafb8d5bce | ||
|
|
9f419a2503 | ||
|
|
6b3714f2bc | ||
|
|
28a13d619c | ||
|
|
ba06c1a7a6 | ||
|
|
f8bd97229b | ||
|
|
93e375dc7d | ||
|
|
2c42aeb4ee | ||
|
|
4f3c39fee8 |
6
.gitignore
vendored
@@ -1,3 +1,7 @@
|
|||||||
|
*.log
|
||||||
|
.env.*
|
||||||
|
backend/razorpay.env
|
||||||
|
backend/src/main/resources/application-local.properties
|
||||||
|
counter-frontend/.env
|
||||||
ordering-site/*
|
ordering-site/*
|
||||||
ordering_site/
|
ordering_site/
|
||||||
counter-frontend/
|
|
||||||
@@ -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
|
## 🏗️ Ecosystem Architecture
|
||||||
|
|
||||||
|
|||||||
BIN
RIT_Logo.png
|
Before Width: | Height: | Size: 371 KiB |
BIN
Ritz/.DS_Store
vendored
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
Ritz/Ritz.psd
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
Ritz/front.png
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 348 KiB |
24
api-dashboard/.gitignore
vendored
Normal 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
@@ -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.*
|
||||||
22
api-dashboard/eslint.config.js
Normal 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
@@ -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
31
api-dashboard/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
api-dashboard/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
api-dashboard/public/icons.svg
Normal 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
1927
api-dashboard/src/App.tsx
Normal file
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
BIN
api-dashboard/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
api-dashboard/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
api-dashboard/src/assets/react.svg
Normal 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 |
1
api-dashboard/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
16
api-dashboard/src/firebase.ts
Normal 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();
|
||||||
73
api-dashboard/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
10
api-dashboard/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
26
api-dashboard/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
api-dashboard/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
api-dashboard/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
7
api-dashboard/vite.config.ts
Normal 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
@@ -31,3 +31,9 @@ build/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
### Razorpay secrets (NEVER commit live keys) ###
|
||||||
|
razorpay.env
|
||||||
|
|
||||||
|
### Logs ###
|
||||||
|
*.log
|
||||||
|
|||||||
@@ -87,6 +87,12 @@
|
|||||||
<artifactId>bucket4j-core</artifactId>
|
<artifactId>bucket4j-core</artifactId>
|
||||||
<version>8.10.1</version>
|
<version>8.10.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Razorpay Payments -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.razorpay</groupId>
|
||||||
|
<artifactId>razorpay-java</artifactId>
|
||||||
|
<version>1.4.8</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
8
backend/razorpay.env.example
Normal 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
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/login").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()
|
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||||
|
|
||||||
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
|
// ── 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) ──
|
// ── PUBLIC: Device log ingestion (ESP32 Bill-Bot devices, no JWT) ──
|
||||||
.requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll()
|
.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) ──
|
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
|
||||||
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
|
.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/balance/**").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
|
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/wallet/topup").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/coupons/redeem").authenticated()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/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/auth/users/*").authenticated()
|
||||||
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
|
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/orders/*/cancel").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")
|
.requestMatchers("/api/counter/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
|
||||||
|
|
||||||
// ── STAFF/MANAGER/MASTER: All other management APIs ──
|
// ── STAFF/MANAGER/MASTER: All other management APIs ──
|
||||||
@@ -108,6 +120,7 @@ public class SecurityConfig {
|
|||||||
List<String> origins = Arrays.asList(allowedOriginsStr.split(","));
|
List<String> origins = Arrays.asList(allowedOriginsStr.split(","));
|
||||||
configuration.setAllowedOrigins(origins);
|
configuration.setAllowedOrigins(origins);
|
||||||
configuration.setAllowedOriginPatterns(List.of(
|
configuration.setAllowedOriginPatterns(List.of(
|
||||||
|
"*",
|
||||||
"http://localhost:*",
|
"http://localhost:*",
|
||||||
"http://127.0.0.1:*",
|
"http://127.0.0.1:*",
|
||||||
"http://192.168.*:*",
|
"http://192.168.*:*",
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package com.rit.canteen.sales.config;
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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
|
@Configuration
|
||||||
public class WebConfig {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
// CORS handled by SecurityConfig — do not add CorsRegistry here
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperApiLogInterceptor logInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(logInterceptor).addPathPatterns("/api/developer/v1/**");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ public class OrderController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TokenService tokenService;
|
private TokenService tokenService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.rit.canteen.sales.service.OrderPlacementService orderPlacementService;
|
||||||
|
|
||||||
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
||||||
|
|
||||||
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
|
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
|
||||||
@@ -138,105 +141,42 @@ public class OrderController {
|
|||||||
order.setOrderType("POS");
|
order.setOrderType("POS");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
// Customers cannot mark an order as paid via RAZORPAY without going through PaymentController
|
||||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
|
if (!isStaff() && order.getPaymentMethod() != null
|
||||||
}
|
&& !"RITZ_TOKEN".equalsIgnoreCase(order.getPaymentMethod())) {
|
||||||
|
|
||||||
// ── 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) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"success", false,
|
"success", false,
|
||||||
"message", "Price mismatch detected. Please refresh and try again.",
|
"message", "Online payments must be completed via Razorpay checkout first."
|
||||||
"serverTotal", serverTotal,
|
|
||||||
"clientTotal", order.getTotalAmount()
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Always use server-calculated total
|
|
||||||
order.setTotalAmount(serverTotal);
|
|
||||||
|
|
||||||
// ── Stock check & update ──────────────────────────────────────────
|
if (order.getPaymentMethod() == null || order.getPaymentMethod().isBlank()) {
|
||||||
List<Map<String, Object>> stockConflicts = new ArrayList<>();
|
order.setPaymentMethod(isStaff() ? "CASH" : "RITZ_TOKEN");
|
||||||
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 (!stockConflicts.isEmpty()) {
|
try {
|
||||||
requestConflicts.set(stockConflicts);
|
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");
|
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 ──────────────────────────────────────────────
|
// ── CUSTOMER: own orders ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
||||||
LoginResponse response = userService.logout(request.getMobileNumber());
|
LoginResponse response = userService.logout(request.getMobileNumber());
|
||||||
|
|||||||
@@ -70,9 +70,21 @@ public class WalletController {
|
|||||||
return ResponseEntity.ok(userList);
|
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")
|
@PostMapping("/topup")
|
||||||
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
||||||
try {
|
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());
|
Long userId = Long.valueOf(request.get("userId").toString());
|
||||||
if (!canAccessUser(userId)) {
|
if (!canAccessUser(userId)) {
|
||||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
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)"));
|
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);
|
User updatedUser = tokenService.topUp(userId, amount, ref);
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"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")
|
@GetMapping("/transactions/all")
|
||||||
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
|
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
|
||||||
return ResponseEntity.ok(tokenService.getAllTransactions());
|
return ResponseEntity.ok(tokenService.getAllTransactions());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ public class LoginResponse {
|
|||||||
private boolean isLoggedIn;
|
private boolean isLoggedIn;
|
||||||
private boolean isSuspended;
|
private boolean isSuspended;
|
||||||
private java.math.BigDecimal ritzTokenBalance;
|
private java.math.BigDecimal ritzTokenBalance;
|
||||||
|
private String membership;
|
||||||
|
|
||||||
public UserDto() {}
|
public UserDto() {}
|
||||||
|
|
||||||
@@ -61,6 +62,16 @@ public class LoginResponse {
|
|||||||
this.ritzTokenBalance = ritzTokenBalance;
|
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 Long getId() { return id; }
|
||||||
public void setId(Long id) { this.id = 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 void setLoggedIn(boolean loggedIn) { this.isLoggedIn = loggedIn; }
|
||||||
|
|
||||||
public boolean isSuspended() { return isSuspended; }
|
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 java.math.BigDecimal getRitzTokenBalance() { return ritzTokenBalance; }
|
||||||
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
|
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
|
||||||
|
|
||||||
|
public String getMembership() { return membership; }
|
||||||
|
public void setMembership(String membership) { this.membership = membership; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -40,6 +40,12 @@ public class User {
|
|||||||
@Column(nullable = false, columnDefinition = "boolean default false")
|
@Column(nullable = false, columnDefinition = "boolean default false")
|
||||||
private boolean isSuspended = false;
|
private boolean isSuspended = false;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = true)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private String membership;
|
||||||
|
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
private LocalDateTime lastLoginAt;
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
@@ -75,6 +81,12 @@ public class User {
|
|||||||
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
|
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
|
||||||
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = 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
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,12 +9,16 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
||||||
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
||||||
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
||||||
|
|
||||||
|
Optional<TokenTransaction> findByReferenceId(String referenceId);
|
||||||
|
boolean existsByReferenceId(String referenceId);
|
||||||
|
|
||||||
@Query("SELECT SUM(t.amount) FROM TokenTransaction t WHERE t.type = :type")
|
@Query("SELECT SUM(t.amount) FROM TokenTransaction t WHERE t.type = :type")
|
||||||
BigDecimal sumByType(@Param("type") TokenTransaction.TransactionType type);
|
BigDecimal sumByType(@Param("type") TokenTransaction.TransactionType type);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import java.util.Optional;
|
|||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
Optional<User> findByMobileNumber(String mobileNumber);
|
Optional<User> findByMobileNumber(String mobileNumber);
|
||||||
boolean existsByMobileNumber(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, '%')) " +
|
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
|
||||||
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ package com.rit.canteen.sales.service;
|
|||||||
|
|
||||||
import com.rit.canteen.sales.model.BaseItem;
|
import com.rit.canteen.sales.model.BaseItem;
|
||||||
import com.rit.canteen.sales.model.Product;
|
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.BaseItemRepository;
|
||||||
import com.rit.canteen.sales.repository.ProductRepository;
|
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.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
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.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class DatabaseSeeder implements CommandLineRunner {
|
public class DatabaseSeeder implements CommandLineRunner {
|
||||||
@@ -21,9 +28,24 @@ public class DatabaseSeeder implements CommandLineRunner {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ProductRepository productRepository;
|
private ProductRepository productRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StallRepository stallRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SystemUserRepository systemUserRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BCryptPasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private JdbcTemplate jdbcTemplate;
|
private JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Value("${app.master.username:admin}")
|
||||||
|
private String masterUsername;
|
||||||
|
|
||||||
|
@Value("${app.master.password:admin}")
|
||||||
|
private String masterPassword;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) throws Exception {
|
public void run(String... args) throws Exception {
|
||||||
repairStallsSchema();
|
repairStallsSchema();
|
||||||
@@ -34,6 +56,8 @@ public class DatabaseSeeder implements CommandLineRunner {
|
|||||||
repairLobColumns();
|
repairLobColumns();
|
||||||
seedCategories();
|
seedCategories();
|
||||||
seedProducts();
|
seedProducts();
|
||||||
|
seedStalls();
|
||||||
|
seedMasterUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void repairFeedbackSchema() {
|
private void repairFeedbackSchema() {
|
||||||
@@ -254,4 +278,45 @@ public class DatabaseSeeder implements CommandLineRunner {
|
|||||||
p.setActive(true);
|
p.setActive(true);
|
||||||
return p;
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ public class UserService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
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);
|
return new LoginResponse(true, "Registration successful. You are now logged in.", userDto);
|
||||||
@@ -81,7 +81,7 @@ public class UserService {
|
|||||||
|
|
||||||
if (user.isSuspended()) {
|
if (user.isSuspended()) {
|
||||||
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
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);
|
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.", userDto);
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ public class UserService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
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);
|
return new LoginResponse(true, "Login successful.", userDto);
|
||||||
@@ -161,7 +161,7 @@ public class UserService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
User user = userOpt.get();
|
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.getName(),
|
||||||
user.isLoggedIn(),
|
user.isLoggedIn(),
|
||||||
user.isSuspended(),
|
user.isSuspended(),
|
||||||
user.getRitzTokenBalance()
|
user.getRitzTokenBalance(),
|
||||||
|
user.getMembership()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +221,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userRepository.save(user);
|
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);
|
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) {
|
public void deleteUser(Long userId) {
|
||||||
userRepository.deleteById(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ server.port=8080
|
|||||||
# DATABASE — REQUIRED environment variables
|
# DATABASE — REQUIRED environment variables
|
||||||
# Set these in your environment or a .env file
|
# 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.username=postgres
|
||||||
spring.datasource.password=sidharth
|
spring.datasource.password=RITHosting123
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
@@ -20,14 +20,14 @@ spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
|
|||||||
# JWT — REQUIRED environment variables
|
# JWT — REQUIRED environment variables
|
||||||
# MUST provide a secure random key (min 256-bit)
|
# 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
|
app.jwt.expiration-ms=86400000
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Master Account — REQUIRED environment variables
|
# Master Account — REQUIRED environment variables
|
||||||
# ============================================================
|
# ============================================================
|
||||||
app.master.username=${MASTER_USER:admin}
|
app.master.username=${MASTER_USER:admin}
|
||||||
app.master.password=${MASTER_PASSWORD}
|
app.master.password=${MASTER_PASSWORD:admin}
|
||||||
|
|
||||||
# File upload configuration
|
# File upload configuration
|
||||||
spring.servlet.multipart.max-file-size=10MB
|
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.coyote.http11.Http11InputBuffer=ERROR
|
||||||
logging.level.org.apache.tomcat.util.http.parser.HttpParser=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:}
|
||||||
|
|
||||||
|
|||||||
BIN
counter-frontend/dist/assets/college-logo-B5J1bvCK.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
2
counter-frontend/dist/assets/index-8tt0cn-G.css
vendored
Normal file
99
counter-frontend/dist/assets/index-C3UP9F-T.js
vendored
Normal file
BIN
counter-frontend/dist/assets/ritchennai-jdoyOWFh.webp
vendored
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 4.8 MiB |
14
counter-frontend/dist/index.html
vendored
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
../acorn/bin/acorn
|
||||||
1
counter-frontend/node_modules/.bin/autoprefixer
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../autoprefixer/bin/autoprefixer
|
||||||
1
counter-frontend/node_modules/.bin/baseline-browser-mapping
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../baseline-browser-mapping/dist/cli.cjs
|
||||||
1
counter-frontend/node_modules/.bin/browserslist
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../browserslist/cli.js
|
||||||
1
counter-frontend/node_modules/.bin/eslint
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../eslint/bin/eslint.js
|
||||||
1
counter-frontend/node_modules/.bin/jiti
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../jiti/lib/jiti-cli.mjs
|
||||||
1
counter-frontend/node_modules/.bin/js-yaml
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../js-yaml/bin/js-yaml.js
|
||||||
1
counter-frontend/node_modules/.bin/jsesc
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../jsesc/bin/jsesc
|
||||||
1
counter-frontend/node_modules/.bin/json5
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../json5/lib/cli.js
|
||||||
1
counter-frontend/node_modules/.bin/nanoid
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../nanoid/bin/nanoid.cjs
|
||||||
1
counter-frontend/node_modules/.bin/node-which
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../which/bin/node-which
|
||||||
1
counter-frontend/node_modules/.bin/parser
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../@babel/parser/bin/babel-parser.js
|
||||||
1
counter-frontend/node_modules/.bin/rolldown
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../rolldown/bin/cli.mjs
|
||||||
1
counter-frontend/node_modules/.bin/semver
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../semver/bin/semver.js
|
||||||
1
counter-frontend/node_modules/.bin/tsc
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../typescript/bin/tsc
|
||||||
1
counter-frontend/node_modules/.bin/tsserver
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../typescript/bin/tsserver
|
||||||
1
counter-frontend/node_modules/.bin/update-browserslist-db
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../update-browserslist-db/cli.js
|
||||||
1
counter-frontend/node_modules/.bin/vite
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../vite/bin/vite.js
|
||||||
2870
counter-frontend/node_modules/.package-lock.json
generated
vendored
Normal file
74
counter-frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||