Double-entry ledger + secure UPI payments — with JWT auth and strict sender ownership validation.
Double-Entry Payment Ledger System is a fintech-style Spring Boot backend that models money movement using an immutable double-entry ledger, plus UPI payment flows and a React dashboard.
The security centerpiece is full sender ownership validation: even if someone knows an account number or UPI ID, they still cannot initiate payments unless the authenticated JWT user truly owns the sender account.
In financial systems, storing balances directly can lead to inconsistencies due to concurrent updates, retries, and partial failures.
This system avoids that by:
- Deriving balances from transaction history (ledger entries)
- Enforcing double-entry accounting (every movement is DEBIT + CREDIT)
- Using immutable logs for auditability and easier reconciliation
▶️ Live Demo page: https://aryandevcodes.github.io/Bank-Ledger-Payment-Engine/- 📄 Local demo HTML (video embed): demo/index.html
Diagram assets live in demo/docs/.
What this diagram is showing (end-to-end layers):
- Client (Postman / Frontend) calls either
/transaction(transfer) or/upi/pay - JWT Auth gates protected endpoints before business logic executes
- API Layer (Spring Boot Controllers) receives the request and delegates
- Service Layer orchestrates use-cases:
TransactionServicefor transfers (API:/transaction)UpiServicefor UPI payments (API:/upi/pay)LedgerServicefor posting ledger entries
- Core Engine contains the “correctness primitives”:
- Idempotency Handler (safe retries)
- Transaction Validator (amount/account invariants)
- Ledger Processor (double-entry posting)
- Debit = Credit enforcement (system invariant)
- Persistence Layer (PostgreSQL) stores:
- Transactions table (status + snapshot)
- Ledger table (append-only, the source of truth)
- UPI tables (profiles + payment objects)
- Account table (identity/metadata; balance correctness comes from ledger)
- The right-side bracket highlights the atomic transaction boundary: either the whole ledger+status update commits, or everything rolls back.
What to notice in this ERD:
banks → account: a bank has many accountsaccount → upi_profiles: an account can have UPI profiles vialinked_account_idupi_profiles → upi_payment_obj: UPI payment requests are tracked with a persisted object containing:transaction_ididempotency_key
upi_payment_obj → transactions: the payment object links to the canonical transaction recordtransactions → ledger: ledger entries reference the transaction viareference_id (transaction_id)
Why this matters:
- Idempotency is stateful: the
upi_payment_objrow is where duplicates/retries get deduplicated. - Audit is reconstructable:
transactionsgive business context;ledgergives immutable financial truth. - Balance is not stored (as the diagram notes): balance is derived from ledger entries, preventing drift.
How the ledger processing works (the invariant):
- Input:
from_account,to_account,amount - Validation:
amount > 0and both accounts are valid - Process:
- Step 1: create DEBIT entry (
account_id = sender) - Step 2: create CREDIT entry (
account_id = receiver)
- Step 1: create DEBIT entry (
- Check (hard invariant):
Sum(DEBIT) == Sum(CREDIT)— always - Store: append to the ledger table (append-only)
Result: balances can be computed from the ledger as credits minus debits, which is safer than trusting a mutable “balance” column.
This is the detailed execution path the system follows:
- User initiates payment (UPI / transfer)
- API receives request and validates input
- Idempotency check (
upi_payment_obj):- If the key already exists → return previous result
- If it’s a failed/invalid previous attempt → surfaced via stored failure status
- Create transaction record (transactions table) with
status = PROCESSING - Validate accounts (
from_account_id,to_account_idmust exist) - Ledger processing (CRITICAL - Double Entry):
- Ledger Entry 1: sender DEBIT amount = X
- Ledger Entry 2: receiver CREDIT amount = X
- Store in ledger table (append-only)
- Update transaction status →
SUCCESS/FAILED - Update upi_payment_obj with final status +
reference transaction_id - Return response
Failure behavior shown in red:
- On errors, the system rolls back the transaction, marks the business transaction as FAILED, and persists a failure_reason (in
upi_payment_obj) so retries are safe and diagnosable.
This is the decision tree used for safe retries:
- Request arrives with an
idempotency_key - Check if a matching record exists in
upi_payment_obj - If it exists → return the stored response (no double-debit)
- If it does not exist → execute the payment, then store key + result
- Return result to the client
The diagram’s key point: idempotency prevents duplicate payments caused by retry issues or network failures.
- 📒 Double-entry ledger (DEBIT + CREDIT) for every transaction
- 🧮 Balances derived from ledger (reduces “stored balance drift”)
- 🔐 JWT authentication + role-based access (Spring Security)
- 🧾 Audit-friendly trail with immutable ledger references
- 🪙 Secure UPI payments + UPI profiles (link UPI → account)
- 🔁 Idempotent UPI payment execution (safe retries)
- 🖥️ React dashboard (admin/manager/auditor/user views)
- Duplicate requests handled via idempotency keys (safe retries)
- Partial failures avoided using atomic database transactions
- Invalid financial states prevented via strict debit/credit + validation rules
- Immutable ledger increases storage, but dramatically improves auditability
- Balance derivation can be slower than stored balances, but ensures correctness
- Strict validation reduces flexibility, but prevents silent data corruption
Backend
- Java 21
- Spring Boot (Web, Data JPA, Validation, Security)
- PostgreSQL
- JWT (JJWT)
- Swagger / OpenAPI (springdoc)
Frontend
- React + Vite
- Tailwind CSS + shadcn/ui (Radix)
- Java 21
- PostgreSQL running locally
- Node.js 18+ (or Bun)
Update the database configuration in src/main/resources/application.yml.
Tip: use environment variables in real deployments. This repo’s default
application.ymlis aimed at local development.
Windows:
.\mvnw.cmd spring-boot:runmacOS/Linux:
./mvnw spring-boot:runBackend base URL: http://localhost:8080
cd bank-frontend
npm installCreate bank-frontend/.env.local:
VITE_API_BASE_URL=http://localhost:8080
VITE_ENABLE_MOCKS=falseRun:
npm run devFrontend URL: http://localhost:8081
- Swagger UI:
http://localhost:8080/swagger-ui.html - OpenAPI JSON:
http://localhost:8080/v3/api-docs
Critical protection against Insecure Direct Object References (IDOR):
- Payment endpoints reject requests where the authenticated user does not own the source UPI/account.
- Verified server-side in
UpiResolver.resolveAndVerifyOwnership():- Matches JWT username against the UPI-linked
Customer → User.username - Throws
AccessDeniedException(→ HTTP 403) on mismatch
- Matches JWT username against the UPI-linked
- Prevents: guessing UPI IDs, using stolen account numbers, unauthorized debits
- Stateless JWT (Spring Security 6 + JJWT)
- Protected endpoints require
Authorization: Bearer <token> @EnableMethodSecurityready for future@PreAuthorizerole/ownership checks
The following backend hardening updates were implemented in this session:
-
Customer-only payment initiation
POST /transactionis restricted toROLE_USER.POST /upi/payis restricted toROLE_USER.- Result: admin/manager/auditor/customer-manager cannot initiate debits on behalf of customers.
-
Server-side sender ownership enforcement in transfer flow
TransactionServiceIMPL.makeTransaction()now validates authenticated principal ownership of sender account before ledger posting.- Result: even with a valid token, users cannot transfer from accounts they do not own.
-
Compliance/KYC update API added for account workflows
- New endpoint:
PATCH /account/{accNumber}/compliance - Supports update of
accountStatus,kycStatus, andcustomerStatus. - Implemented through dedicated DTO + service method + mapper response fields.
- New endpoint:
-
Compliance fields exposed in account responses
AccountResponseDTOnow includes customer compliance fields (kycStatus,customerStatus) for admin/compliance UIs.
- Swagger UI (runtime):
http://localhost:8080/swagger-ui.html - OpenAPI JSON (runtime):
http://localhost:8080/v3/api-docs
Bundled specs:
This project implements traditional accounting:
- Every transaction creates two ledger entries (DEBIT & CREDIT)
- Ledger entries are treated as immutable financial records
- Balances can be computed from ledger history
Ledger example (transfer ₹5,000)
- Debit sender ₹5,000
- Credit receiver ₹5,000
| entry_type | amount | meaning |
|---|---|---|
| DEBIT | 5000 | money leaves sender |
| CREDIT | 5000 | money enters receiver |
Frontend lives in bank-frontend/.
Includes:
- Auth (token storage + protected routing)
- Role-based dashboards
- Banking flows (banks/customers/accounts/transactions)
- UPI flows (profiles + payments)
- Security/audit screens (audit logs, access logs, sessions)
Frontend scripts
cd bank-frontend
npm run dev
npm run build
npm run preview
npm run test- Frontend shows empty data → confirm backend is running and
VITE_API_BASE_URLpoints to it - Port conflicts → keep frontend on 8081 and backend on 8080 (or change Vite port)
- Database connection fails → verify PostgreSQL is up and
application.ymlcredentials match
- Project report: PROJECT_REPORT.md





