Compare commits

..
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 e4a2c00d75 feat(cic): switch CIC module to enquiry-alert service
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Remove CIBIL enquiry/consent flows; add alert fetch (manual + scheduled),
customer matching, fetch-run log, dashboard and ENQUIRY_ALERT mapping
(Liquibase 0605).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:14:18 +05:30
niteshandClaude Opus 5.5 a8f8aa39b7 feat(cic): credit bureau (CIBIL / CIC) enquiry module
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Whitelist and configure CICs (CIBIL, Experian, Equifax, CRIF) with encrypted
credentials, UI-driven request/response API mapping, consent register,
enquiries with report reuse, simulator mode, and a MENU_CREDIT_BUREAU menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:17:50 +05:30
niteshandClaude Opus 5.5 3a320b9c09 feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
New FixedDepositFundingValidator, used on every path money can reach an FD:
- an FD is funded once, with exactly its principal: it must be active and not matured,
  hold no deposit yet, and the amount must equal its deposit amount;
- when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account
  must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK /
  BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them,
  and have enough available balance after holds, lien, clearing holds and minimum
  balance (plus overdraft for current accounts).

Applied in:
- FD deposit command (counter / FD screen / approved cashier deposit requests);
- cashier deposit request submit, so the counter sees the reason at once;
- clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again
  at approval before the request is locked; one request funds FDs of one customer, an
  FD appears once, and an FD can never be a debit leg. After posting, each funded FD's
  maturity date and amount are recomputed (new
  DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct
  book credit does not do it;
- clerk withdrawal request submit refuses an FD: money leaves an FD only through its
  maturity or premature closure.

Previously a direct book debit in Collection / Bank Transfer could take money out of an
FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of
times for any amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Opus 5.5 8ba0420398 feat(deposits): allow several FD/RD accounts on the same product for one client
A client may now open any number of Fixed Deposit and Recurring Deposit accounts on
the same product, even while an earlier one is still active. The one-active-account-
per-product check is removed from submitFDApplication and submitRDApplication only;
the same rule on plain Savings and Current accounts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Sonnet 5.5 6be32272c1 feat(cashier): maker-checker on cash transfer
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and
posted only when a different manager or head cashier approves it.

- POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same
  checks run again at approval, since the vault can change in between.
- Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a
  reason; the maker cannot approve their own request.
- An identical request from the same maker is refused while the first is still pending.
- The direct POST /cashtransfer is closed so the approval cannot be bypassed.
- Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:22 +05:30
niteshandClaude Sonnet 5.5 b47a34b5ef feat(vouchers): voucher correction for cash deposits posted to the wrong account
A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back:
the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a
manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the
same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it.

- VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction,
  approve/reject correction, close request, voided transactions.
- The wrong account's balance is checked when the cashier enters the correction and again at approval.
- A cashier whose customer has already used the money closes the request and deposits separately.
- Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for
  a manager or head cashier other than the maker.
- Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher,
  since the correction status is read from m_voucher_correction alone.
- Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status
  and leave a revoked voucher out of their totals, so its cash is not counted twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:21 +05:30
niteshandClaude Sonnet 5.5 04d1eb20f0 feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same
maker-checker path as a savings withdrawal:
- the request carries gl_account_id, contra_gl_id, office_id and currency_code
  (new nullable columns, changeset 0600) instead of a savings account;
- at submit it is validated: the paying GL must be a mapped cash vault GL and the
  slip's own GL must not be a cash GL;
- at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry;
- the cashier pays it against its token, and the denomination and voucher lines are
  recorded under the journal entry's id. KYC debit checks are skipped for GL requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:01 +05:30
niteshandClaude Sonnet 5.5 da1e21f2ca feat(security): block undo/reverse of posted transactions at API level
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Rejects journal entry reverse, savings/current undo and reverse, fixed and recurring deposit
undo, and loan transaction undo (adjust with zero amount) with a clear error, so posted entries
cannot be reversed by calling the API directly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:42:01 +05:30
niteshandClaude Sonnet 5.5 b1eea68b17 feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and branch position API
- Cash exchange: customer name/mobile/PAN (PAN mandatory above Rs 50,000), denomination validation
  against m_denomination_master, cashier required; Liquibase 0599 adds the three columns.
- Inter-cashier: sender must be the logged-in cashier, cash-allocated validation, new
  cashier-summary endpoint.
- Reconciliation restricted to the logged-in cashier's own records.
- New cashreport/branch-position endpoint (vault + cashier drawers, denomination-wise).
- Customer search returns the KYC PAN.
- Reverse endpoints removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:42:00 +05:30
41 changed files with 5198 additions and 319 deletions
@@ -60,6 +60,7 @@ import org.apache.fineract.infrastructure.core.api.DateParam;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.DateFormat;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -229,6 +230,11 @@ public class JournalEntriesApiResource {
@PathParam("transactionId") @Parameter(description = "transactionId") final String transactionId,
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
CommandProcessingResult result;
if (is(commandParam, "reverse")) {
throw new PlatformDataIntegrityException("error.msg.journalentry.reverse.not.allowed",
"Reversing a journal entry is not allowed.");
}
if (is(commandParam, "reverse")) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().reverseJournalEntry(transactionId).withJson(jsonRequestBody)
.build();
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
/**
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
@@ -16,6 +16,8 @@ import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -29,6 +31,7 @@ import org.apache.fineract.portfolio.denominationmaster.service.DenominationBrea
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetailRepository;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeRepository;
import org.apache.fineract.portfolio.client.data.ClientKycApiConstants;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -50,6 +53,12 @@ public class CashExchangeApiResource {
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* Above this amount the customer's PAN must be recorded: Income Tax Rule 114B requires PAN for cash
* transactions of more than Rs 50,000 in a day, and RBI cash-handling guidelines follow it.
*/
static final BigDecimal PAN_MANDATORY_ABOVE = new BigDecimal("50000");
@POST
@Transactional
public String create(String body) {
@@ -78,15 +87,25 @@ public class CashExchangeApiResource {
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
? json.get("customerIdProof").getAsString() : null;
final String customerName = trimToNull(json, "customerName");
final String customerMobile = trimToNull(json, "customerMobile");
final String panInput = trimToNull(json, "panNumber");
final String panNumber = panInput != null ? panInput.toUpperCase(java.util.Locale.ROOT) : null;
final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received")
: new JsonArray();
final JsonArray given = json.has("given") && json.get("given").isJsonArray() ? json.getAsJsonArray("given") : new JsonArray();
final BigDecimal receivedTotal = sumTotal(received);
final BigDecimal givenTotal = sumTotal(given);
// Each line is checked against the denomination master and carries the master's value: the amount
// of an exchange is never taken from a value the caller sent.
final Map<Long, Integer> masterValues = denominationValues();
final List<Leg> receivedLegs = parseLegs("received", received, masterValues);
final List<Leg> givenLegs = parseLegs("given", given, masterValues);
if (!hasPositiveCount(received) || !hasPositiveCount(given)) {
final BigDecimal receivedTotal = sumTotal(receivedLegs);
final BigDecimal givenTotal = sumTotal(givenLegs);
if (receivedLegs.isEmpty() || givenLegs.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.empty",
"Both received and given denominations are required.");
}
@@ -99,6 +118,19 @@ public class CashExchangeApiResource {
"Both received and given denominations are required.");
}
// PAN is checked whenever it is given, and required above Rs 50,000. The screen enforced the
// threshold on its own, through a free-text "PAN / Aadhaar" box, so an API call -- or any
// text in that box -- recorded a large exchange with no PAN behind it.
if (panNumber != null && !panNumber.matches(ClientKycApiConstants.PAN_REGEX)) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.invalid",
"PAN must be 5 letters, 4 digits and 1 letter (e.g. ABCDE1234F).", "panNumber", panNumber);
}
if (receivedTotal.compareTo(PAN_MANDATORY_ABOVE) > 0 && panNumber == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.required",
"Customer PAN is required for a cash exchange above Rs 50,000 (amount " + receivedTotal + ").", "panNumber",
receivedTotal);
}
CashExchange header = new CashExchange();
header.setAccountId(accountId);
header.setAccountNo(accountNo);
@@ -106,7 +138,12 @@ public class CashExchangeApiResource {
header.setExchangeAmount(receivedTotal);
header.setExchangeDate(exchangeDate);
header.setNote(note);
header.setCustomerIdProof(customerIdProof);
// The Excess Transaction (CTR) report reads customer_id_proof, so the verified PAN fills it when
// nothing else was sent.
header.setCustomerIdProof(customerIdProof != null && !customerIdProof.isBlank() ? customerIdProof : panNumber);
header.setCustomerName(customerName);
header.setCustomerMobile(customerMobile);
header.setPanNumber(panNumber);
header.setIsReversed(false);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
@@ -114,91 +151,48 @@ public class CashExchangeApiResource {
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
// Auto-detect cashier linked to this user
try {
Long cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
header.setCashierId(cashierId);
} catch (Exception ignored) {
// intentionally ignored
}
}
} catch (Exception e) {
// resolving the current user must never block recording the exchange
// who made the entry is recorded when it can be read
}
// Validate that cashier has enough of each denomination being GIVEN to the customer
if (header.getCashierId() != null) {
final java.util.Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId());
final java.util.List<String> shortages = new java.util.ArrayList<>();
for (JsonElement el : given) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
long denomId = item.get("denominationId").getAsLong();
long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
// An exchange takes notes into one cashier's drawer and hands change out of it, so only the
// cashier behind this login can record it, and it is always theirs. It used to go through with no
// cashier at all when the login was not a cashier's: the entry then belonged to no drawer, moved no
// cashier's position, and skipped the stock check below.
final Long cashierId = cashierRoleHelper.getCurrentCashierId();
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.cashier.required",
"A cash exchange can only be recorded by a cashier. Your login is not linked to a cashier.");
}
header.setCashierId(cashierId);
// The cashier has to hold, denomination by denomination, what they hand to the customer. A
// denomination is counted across all its lines (parseLegs merges them): checked line by line, two
// lines of 3 against a stock of 5 each passed although 6 were being given.
final Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(cashierId);
final List<String> shortages = new ArrayList<>();
for (final Leg leg : givenLegs) {
final long available = stock.getOrDefault(leg.denominationId(), 0L);
if (leg.count() > available) {
shortages.add("₹" + leg.value() + " (need " + leg.count() + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
}
CashExchange saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), "RECEIVED", received);
saveDetails(saved.getId(), "GIVEN", given);
saveDetails(saved.getId(), "RECEIVED", receivedLegs);
saveDetails(saved.getId(), "GIVEN", givenLegs);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
public String reverse(@PathParam("id") Long id, String body) {
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
CashExchange exchange = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.already.reversed",
"Cash exchange " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
exchange.setIsReversed(true);
exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
exchange.setReverseReason(reason);
try {
AppUser user = this.context.authenticatedUser();
if (user != null) exchange.setReversedBy(user.getId());
} catch (Exception ignored) {
// intentionally ignored
}
repository.save(exchange);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("accountId") Long accountId,
@@ -238,52 +232,80 @@ public class CashExchangeApiResource {
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
total = total.add(BigDecimal.valueOf((long) value * count));
/** One checked line of an exchange: a denomination from the master, the master's value for it, and how many. */
private record Leg(long denominationId, int value, int count) {
}
/** The value of every denomination in the master (a deleted one is gone; a discontinued one is still there). */
private Map<Long, Integer> denominationValues() {
final Map<Long, Integer> values = new HashMap<>();
for (final Map<String, Object> row : jdbcTemplate
.queryForList("SELECT id, denomination FROM m_denomination_master WHERE deleted = false")) {
values.put(((Number) row.get("id")).longValue(), ((Number) row.get("denomination")).intValue());
}
return values;
}
/**
* The lines of one side with a count, each checked against the denomination master and merged per
* denomination. A line must name a known denomination, and a value the caller sent for it has to be the
* master's: with the caller's value trusted, a request could pass a ₹10 note off as ₹500, make the two
* sides "equal", and shift a drawer's books by the difference. A discontinued denomination is accepted --
* a customer can still bring withdrawn notes to be exchanged.
*/
private List<Leg> parseLegs(final String side, final JsonArray items, final Map<Long, Integer> masterValues) {
final Map<Long, Leg> merged = new LinkedHashMap<>();
for (final JsonElement el : items) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.required",
"Every " + side + " line needs a denomination.");
}
final long id = item.get("denominationId").getAsLong();
final Integer value = masterValues.get(id);
if (value == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.unknown",
"Unknown denomination (id " + id + ") in the " + side + " notes.");
}
if (item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
&& item.get("denominationValue").getAsInt() != value) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.value.mismatch",
"Denomination " + id + " is ₹" + value + ", not ₹" + item.get("denominationValue").getAsInt() + ".");
}
merged.merge(id, new Leg(id, value, count), (a, b) -> new Leg(id, value, a.count() + b.count()));
}
return new ArrayList<>(merged.values());
}
private BigDecimal sumTotal(final List<Leg> legs) {
BigDecimal total = BigDecimal.ZERO;
for (final Leg leg : legs) {
total = total.add(BigDecimal.valueOf((long) leg.value() * leg.count()));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
return true;
}
private static String trimToNull(final JsonObject json, final String key) {
if (!json.has(key) || json.get(key).isJsonNull()) {
return null;
}
return false;
final String value = json.get(key).getAsString().trim();
return value.isEmpty() ? null : value;
}
private void saveDetails(Long exchangeId, String direction, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong()
: null;
CashExchangeDetail detail = new CashExchangeDetail();
private void saveDetails(final Long exchangeId, final String direction, final List<Leg> legs) {
for (final Leg leg : legs) {
final CashExchangeDetail detail = new CashExchangeDetail();
detail.setExchangeId(exchangeId);
detail.setDirection(direction);
detail.setDenominationId(denominationId);
detail.setDenominationValue(value);
detail.setCount(count);
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
detail.setDenominationId(leg.denominationId());
detail.setDenominationValue(leg.value());
detail.setCount(leg.count());
detail.setTotalAmount(BigDecimal.valueOf((long) leg.value() * leg.count()));
detailRepository.save(detail);
}
}
@@ -296,6 +318,9 @@ public class CashExchangeApiResource {
map.put("accountNo", e.getAccountNo());
map.put("clientId", e.getClientId());
map.put("customerIdProof", e.getCustomerIdProof());
map.put("customerName", e.getCustomerName());
map.put("customerMobile", e.getCustomerMobile());
map.put("panNumber", e.getPanNumber());
map.put("exchangeAmount", e.getExchangeAmount());
map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null);
map.put("note", e.getNote());
@@ -52,6 +52,17 @@ public class CashExchange {
@Column(name = "customer_id_proof", length = 50)
private String customerIdProof;
/** Walk-in customer's name; for an existing client, their display name at the time of the exchange. */
@Column(name = "customer_name", length = 150)
private String customerName;
@Column(name = "customer_mobile", length = 20)
private String customerMobile;
/** PAN verified at the counter; mandatory when the exchange is above Rs 50,000. */
@Column(name = "pan_number", length = 10)
private String panNumber;
@Column(name = "performed_by")
private Long performedBy;
@@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -98,6 +99,7 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -155,6 +157,11 @@ public class CashierDepositRequestApiResource {
throw apiError(400, "Amount must be greater than zero");
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
// manager approves; the FD deposit command checks the same again at posting.
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
}
validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue);
@@ -74,6 +74,15 @@ public class CashInterCashierApiResource {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
"From-cashier and To-cashier cannot be the same.");
}
// The cash leaves the sender's drawer, so only the sender can hand it over: the logged-in cashier
// is always the from-cashier. Nobody -- a manager, another cashier -- records a transfer out of
// somebody else's drawer.
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
if (ownCashierId == null || !ownCashierId.equals(fromCashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.from.must.be.own",
"An inter-cashier transfer can only be made by the cashier handing the cash over, from their own"
+ " drawer.");
}
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
@@ -92,6 +101,13 @@ public class CashInterCashierApiResource {
"At least one denomination with a positive count is required.");
}
// Neither side can take part before the vault has allocated them cash for the day: a cashier
// who never opened their till has no morning allocation behind their drawer, so they can
// neither hand cash on nor take it in. Checked first so that is the message they see, not a
// shortfall on some denomination.
validateCashAllocated(fromCashierId, transferDate, "from");
validateCashAllocated(toCashierId, transferDate, "to");
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
// stock and settlement validates the cashier's; this register validated neither, so a cashier
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
@@ -134,45 +150,6 @@ public class CashInterCashierApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
CashInterCashier record = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
"Inter-cashier transfer not found: " + id));
if (Boolean.TRUE.equals(record.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
"Inter-cashier transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
record.setIsReversed(true);
record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
record.setReverseReason(reason);
if (user != null) record.setReversedBy(user.getId());
repository.save(record);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -209,6 +186,77 @@ public class CashInterCashierApiResource {
return gson.toJson(map);
}
/**
* One cashier's details, today's vault allocation and denomination-wise cash, so the inter-cashier
* screen can show both sides before the transfer is recorded. The Cashier Position endpoint cannot
* serve this: for a plain cashier it ignores {@code cashierId} and returns their own position, so
* the receiving cashier's figures would never show.
*/
@GET
@Path("cashier-summary")
public String cashierSummary(@QueryParam("cashierId") final Long cashierId, @QueryParam("date") final String date) {
context.authenticatedUser().validateHasReadPermission("CASHINTERCASHIER");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.required", "cashierId is required.");
}
final LocalDate asOf = date != null && !date.isBlank() ? LocalDate.parse(date) : DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id AS cashier_id, s.display_name AS staff_name, c.teller_id, t.name AS teller_name,
t.office_id, o.name AS office_name
FROM m_cashiers c
LEFT JOIN m_staff s ON s.id = c.staff_id
LEFT JOIN m_tellers t ON t.id = c.teller_id
LEFT JOIN m_office o ON o.id = t.office_id
WHERE c.id = ?
""", cashierId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.not.found",
"Cashier not found: " + cashierId);
}
final Map<String, Object> row = rows.get(0);
final BigDecimal allocated = allocatedAmount(cashierId, asOf);
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(cashierId, asOf.toString());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("cashierId", cashierId);
resp.put("cashierName", row.get("staff_name"));
resp.put("tellerId", row.get("teller_id"));
resp.put("tellerName", row.get("teller_name"));
resp.put("officeId", row.get("office_id"));
resp.put("officeName", row.get("office_name"));
resp.put("asOfDate", asOf.toString());
resp.put("cashAllocated", allocated.signum() > 0);
resp.put("allocatedAmount", allocated);
resp.put("totalAmount", position.get("totalAmount"));
resp.put("denominations", position.get("denominations"));
return gson.toJson(resp);
}
/**
* Cash the vault allocated to the cashier on the date, from the same non-reversed
* {@code m_cash_transfer} rows the "No Cash Allocated" gate on the transaction screens counts.
*/
private BigDecimal allocatedAmount(final Long cashierId, final LocalDate date) {
final BigDecimal amount = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)
AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
""", BigDecimal.class, cashierId, date.toString());
return amount != null ? amount : BigDecimal.ZERO;
}
private void validateCashAllocated(final Long cashierId, final LocalDate date, final String side) {
if (allocatedAmount(cashierId, date).signum() > 0) {
return;
}
final String role = "from".equals(side) ? "sending" : "receiving";
throw new PlatformDataIntegrityException("error.msg.cashintercashier." + side + ".cashier.cash.not.allocated",
"The " + role + " cashier has not been allocated cash from the vault for " + date
+ ". Allocate cash to them through Cash Transfer before an inter-cashier transfer.",
side + "CashierId", cashierId);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
@@ -231,8 +279,8 @@ public class CashInterCashierApiResource {
* Same check, same source of truth and same message shape as
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
* position is left alone rather than refused, matching the settlement path: a cashier with no
* recorded position yet is a setup state, not an overdraft.
* position means the cashier holds nothing: create() only gets here for a cashier the vault has
* allocated cash to, so an empty position is cash already handed on, not a setup state.
*/
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
final LocalDate transferDate) {
@@ -242,12 +290,9 @@ public class CashInterCashierApiResource {
@SuppressWarnings("unchecked")
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows == null || heldRows.isEmpty()) {
return;
}
final Map<Long, Long> held = new java.util.HashMap<>();
for (final Map<String, Object> row : heldRows) {
for (final Map<String, Object> row : heldRows == null ? List.<Map<String, Object>>of() : heldRows) {
final Object id = row.get("denominationId");
final Object count = row.get("count");
if (id instanceof Number && count instanceof Number) {
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -46,8 +47,26 @@ public class CashReconciliationApiResource {
private final CashReconciliationDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* A cashier reconciles only their own drawer. The cashier is picked on the screen, and two cashiers
* under one teller could name each other: one cashier's count then stands in for another's, and the
* reconciliation record is what a settlement is later allowed on the strength of. A user who is not
* a cashier-only login (a manager, the branch head) is not limited.
*/
private void requireOwnCashier(final Long cashierId) {
if (!cashierRoleHelper.isCashierOnly()) {
return;
}
final Long own = cashierRoleHelper.getCurrentCashierId();
if (own == null || !own.equals(cashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.other.cashier",
"A cashier can only reconcile their own cash.");
}
}
/**
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise expected balance for a cashier.
@@ -64,6 +83,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
requireOwnCashier(cashierId);
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
@@ -227,6 +247,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
requireOwnCashier(cashierId);
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
@@ -250,6 +271,14 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId is required.");
}
// A reconciliation is the cashier's own count of their own drawer. Unlike reading one (a manager
// looks at a cashier's, and day close checks each), recording one is the cashier's alone: neither
// another cashier nor a manager can count a drawer on its owner's behalf.
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
if (ownCashierId == null || !ownCashierId.equals(cashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.own.cashier.only",
"A reconciliation can only be recorded by the cashier whose drawer it is.");
}
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
@@ -338,7 +367,11 @@ public class CashReconciliationApiResource {
@GET
public String getAll() {
// A cashier sees their own reconciliations only; everyone else sees all of them.
final boolean cashierOnly = cashierRoleHelper.isCashierOnly();
final Long ownCashierId = cashierOnly ? cashierRoleHelper.getCurrentCashierId() : null;
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.filter(e -> !cashierOnly || (ownCashierId != null && ownCashierId.equals(e.getCashierId())))
.map(this::toMap).collect(Collectors.toList()));
}
@@ -347,6 +380,7 @@ public class CashReconciliationApiResource {
public String getOne(@PathParam("id") Long id) {
CashReconciliation header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
requireOwnCashier(header.getCashierId());
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByReconciliationId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
@@ -14,11 +14,13 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -35,6 +37,7 @@ public class CashReportApiResource {
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final VoucherService voucherService;
private final Gson gson = new Gson();
/**
@@ -328,7 +331,18 @@ public class CashReportApiResource {
BigDecimal priorCreditTotal = BigDecimal.ZERO;
BigDecimal priorDebitTotal = BigDecimal.ZERO;
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
for (final Map<String, Object> row : rows) {
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
@@ -349,7 +363,7 @@ public class CashReportApiResource {
// was already earned, and the day it belongs to has been counted and signed off. Adding
// it to this day's totals would make them disagree with the ledger for either date.
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
@@ -1083,6 +1097,137 @@ public class CashReportApiResource {
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
}
/**
* GET /cashreports/branch-position?officeId=X&asOfDate=yyyy-MM-dd
*
* The notes and coins a branch holds, denomination by denomination: its vault(s) plus every cashier's
* drawer. Cash moving between the vault and a drawer, or from one drawer to another, nets out in this
* total; what changes it is what crosses the counter -- a deposit, a withdrawal, an exchange. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) is +10 on ₹500 and -50 on ₹100 here, and the
* "exchangeNet" column carries that day's exchange movement beside the total so it can be seen.
*
* Until now no screen showed this total: the vault is untouched by an exchange and each drawer is shown
* alone, so what the branch held per denomination was only known from the hand-entered count at day
* open and close. Not for a cashier -- it spans every drawer; they see their own on Cashier Position.
*/
@GET
@Path("branch-position")
public String branchPosition(@QueryParam("officeId") final Long officeIdParam, @QueryParam("asOfDate") final String asOfDate) {
if (cashierRoleHelper.isCashierOnly()) {
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.not.for.cashier",
"The branch cash position covers every drawer; a cashier sees their own on the Cashier Position screen.");
}
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: DateUtils.getBusinessLocalDate().toString();
final Long officeId = officeIdParam != null ? officeIdParam : context.authenticatedUser().getOffice().getId();
final List<String> officeNames = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
if (officeNames.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.office.not.found",
"Office not found: " + officeId);
}
// One row per denomination in the master; a discontinued one (the ₹2000 note) shows only while
// something is still held or moved in it.
final Map<Long, Map<String, Object>> rows = new LinkedHashMap<>();
for (final Map<String, Object> m : jdbcTemplate
.queryForList("SELECT id, denomination, kind, is_active FROM m_denomination_master WHERE deleted = false")) {
final long id = ((Number) m.get("id")).longValue();
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", id);
row.put("denominationValue", ((Number) m.get("denomination")).intValue());
row.put("kind", m.get("kind"));
row.put("active", Boolean.TRUE.equals(m.get("is_active")));
row.put("vaultCount", 0L);
row.put("cashierCount", 0L);
row.put("exchangeNet", 0L);
rows.put(id, row);
}
boolean vaultHasBreakdown = false;
for (final Long tellerId : jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ? ORDER BY id", Long.class,
officeId)) {
final Map<String, Object> vault = vaultPositionService.buildPositionForVault(tellerId, dateFilter);
vaultHasBreakdown = vaultHasBreakdown || Boolean.TRUE.equals(vault.get("hasBreakdown"));
addPositionCounts(rows, vault, "vaultCount");
}
for (final Long cashierId : jdbcTemplate.queryForList(
"SELECT mc.id FROM m_cashiers mc JOIN m_tellers t ON t.id = mc.teller_id WHERE t.office_id = ? ORDER BY mc.id",
Long.class, officeId)) {
addPositionCounts(rows, buildPositionForCashier(cashierId, dateFilter), "cashierCount");
}
// The day's exchanges across the branch's cashiers: notes taken in less notes given out, per denomination.
for (final Map<String, Object> r : jdbcTemplate.queryForList("""
SELECT ced.denomination_id,
SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE -ced.count END) AS net
FROM m_cash_exchange_detail ced
JOIN m_cash_exchange ce ON ce.id = ced.exchange_id
JOIN m_cashiers mc ON mc.id = ce.cashier_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE t.office_id = ? AND ce.exchange_date = CAST(? AS DATE)
AND (ce.is_reversed IS NULL OR ce.is_reversed = false)
GROUP BY ced.denomination_id
""", officeId, dateFilter)) {
final Object id = r.get("denomination_id");
final Object net = r.get("net");
if (id instanceof Number && net instanceof Number && rows.containsKey(((Number) id).longValue())) {
rows.get(((Number) id).longValue()).put("exchangeNet", ((Number) net).longValue());
}
}
final List<Map<String, Object>> lines = new ArrayList<>();
BigDecimal vaultAmount = BigDecimal.ZERO;
BigDecimal cashiersAmount = BigDecimal.ZERO;
for (final Map<String, Object> row : rows.values()) {
final long vaultCount = ((Number) row.get("vaultCount")).longValue();
final long cashierCount = ((Number) row.get("cashierCount")).longValue();
final long exchangeNet = ((Number) row.get("exchangeNet")).longValue();
final int value = ((Number) row.get("denominationValue")).intValue();
if (!Boolean.TRUE.equals(row.get("active")) && vaultCount == 0 && cashierCount == 0 && exchangeNet == 0) {
continue;
}
row.put("totalCount", vaultCount + cashierCount);
row.put("totalAmount", BigDecimal.valueOf(value * (vaultCount + cashierCount)));
vaultAmount = vaultAmount.add(BigDecimal.valueOf(value * vaultCount));
cashiersAmount = cashiersAmount.add(BigDecimal.valueOf(value * cashierCount));
lines.add(row);
}
lines.sort((a, b) -> Integer.compare(((Number) b.get("denominationValue")).intValue(),
((Number) a.get("denominationValue")).intValue()));
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("officeId", officeId);
resp.put("officeName", officeNames.get(0));
resp.put("asOfDate", dateFilter);
resp.put("vaultHasBreakdown", vaultHasBreakdown);
resp.put("vaultAmount", vaultAmount);
resp.put("cashiersAmount", cashiersAmount);
resp.put("totalAmount", vaultAmount.add(cashiersAmount));
resp.put("denominations", lines);
return gson.toJson(resp);
}
/** Adds a position's per-denomination counts (a vault's or a cashier's) into one field of the rows. */
@SuppressWarnings("unchecked")
private void addPositionCounts(final Map<Long, Map<String, Object>> rows, final Map<String, Object> position,
final String field) {
final List<Map<String, Object>> lines = (List<Map<String, Object>>) position.get("denominations");
if (lines == null) {
return;
}
for (final Map<String, Object> line : lines) {
final Object id = line.get("denominationId");
final Object count = line.get("count");
if (!(id instanceof Number) || !(count instanceof Number)) {
continue;
}
final Map<String, Object> row = rows.get(((Number) id).longValue());
if (row != null) {
row.merge(field, ((Number) count).longValue(), (a, b) -> ((Number) a).longValue() + ((Number) b).longValue());
}
}
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
@@ -33,7 +33,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
@@ -181,64 +180,6 @@ public class CashSettlementApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
CashSettlement settlement = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
"Settlement not found: " + id));
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
"Settlement " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
settlement.setIsReversed(true);
settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
settlement.setReverseReason(reason);
if (user != null) settlement.setReversedBy(user.getId());
repository.save(settlement);
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
try {
Long glTellerId = settlement.getToTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.allocateCashToCashier(glTellerId,
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
@Path("check")
public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) {
@@ -35,7 +35,6 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -58,9 +57,23 @@ public class CashTransferApiResource {
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
* {@link #postTransfer}.
*/
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
"A cash transfer is raised as a request and posted when a manager approves it.");
}
/**
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
* when it is approved, since the vault can change in between.
*/
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -103,6 +116,9 @@ public class CashTransferApiResource {
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
@@ -113,15 +129,9 @@ public class CashTransferApiResource {
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Recorded against the person who raised the request, not the one who approved it.
header.setPerformedBy(performedBy);
header.setPerformedByUsername(performedByUsername);
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -160,65 +170,171 @@ public class CashTransferApiResource {
return gson.toJson(result);
}
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
@Path("requests")
public String submitRequest(final String body) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
postTransfer(body, user.getId(), user.getUsername(), true);
CashTransfer transfer = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
"Cash transfer not found: " + id));
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
"Cash transfer " + id + " is already reversed.");
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
// a second click would otherwise release the same cash twice once both were approved.
final Integer identical = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
REQUEST_PENDING, user.getId(), body);
if (identical != null && identical > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
"An identical cash transfer request from you is already waiting for approval.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
try {
AppUser user = this.context.authenticatedUser();
transfer.setReversedBy(user.getId());
} catch (Exception ignored) {
// intentionally ignored
}
transfer.setIsReversed(true);
transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
transfer.setReverseReason(reason);
repository.save(transfer);
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
try {
Long glTellerId = transfer.getFromTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.settleCashFromCashier(glTellerId,
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final JsonArray denominations = json.getAsJsonArray("denominations");
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_cash_transfer_request
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
status, maker_id, maker_name, maker_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
result.addProperty("status", REQUEST_PENDING);
return gson.toJson(result);
}
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
@GET
@Path("requests")
public String listRequests(@QueryParam("status") final String status,
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
final List<Object> params = new java.util.ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND maker_id = ?");
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
for (final Map<String, Object> row : rows) {
// The notes asked for, so the approver sees what they are releasing without opening anything.
final Object requestBody = row.remove("request_body");
row.put("denominations", requestBody == null ? new JsonArray()
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
}
return gson.toJson(rows);
}
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
@POST
@Path("requests/{id}/approve")
public String approveRequest(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "approved");
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
if (makerId != null && makerId.equals(user.getId())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
}
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
final int updated = jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
WHERE id = ? AND status = ?
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
transferId, id, REQUEST_PENDING);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
"This request was already actioned by another manager.");
}
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_APPROVED);
if (transferId != null) {
result.addProperty("cashTransferId", transferId);
}
if (postedJson.has("glWarning")) {
result.add("glWarning", postedJson.get("glWarning"));
}
return gson.toJson(result);
}
@POST
@Path("requests/{id}/reject")
public String rejectRequest(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "rejected");
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
final String reason = textOrNull(json, "reason");
if (reason == null || reason.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
}
jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
WHERE id = ? AND status = ?
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
reason.trim(), id, REQUEST_PENDING);
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_REJECTED);
return gson.toJson(result);
}
private static final String REQUEST_PENDING = "PENDING";
private static final String REQUEST_APPROVED = "APPROVED";
private static final String REQUEST_REJECTED = "REJECTED";
private Map<String, Object> loadRequest(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
}
return rows.get(0);
}
private void requirePending(final Map<String, Object> request, final String action) {
if (!REQUEST_PENDING.equals(request.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
}
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void requireChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
}
}
private static String textOrNull(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -0,0 +1,319 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cic.service.CicAlertService;
import org.apache.fineract.portfolio.cic.service.CicCatalog;
import org.apache.fineract.portfolio.cic.service.CicConfigService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
* the bank's customers is enquired by another lender.
*
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
*/
@Path("/v1/cic")
@Component
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
@RequiredArgsConstructor
public class CicApiResource {
private final PlatformSecurityContext context;
private final CicConfigService configService;
private final CicAlertService alertService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Catalog ───────────────────────────────────────────────────────────────
@GET
@Path("catalog")
@Produces(MediaType.APPLICATION_JSON)
public String catalog() {
context.authenticatedUser();
final Map<String, Object> c = new LinkedHashMap<>();
c.put("environments", CicCatalog.ENVIRONMENTS);
c.put("authTypes", CicCatalog.AUTH_TYPES);
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
c.put("httpMethods", CicCatalog.HTTP_METHODS);
c.put("cbsFields", CicCatalog.CBS_FIELDS);
c.put("resultFields", CicCatalog.RESULT_FIELDS);
c.put("rootFields", CicCatalog.ROOT_FIELDS);
c.put("transforms", CicCatalog.TRANSFORMS);
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
c.put("matchTypes", CicCatalog.MATCH_TYPES);
return gson.toJson(c);
}
// ── Bureaus ───────────────────────────────────────────────────────────────
@GET
@Path("bureaus")
@Produces(MediaType.APPLICATION_JSON)
public String bureaus(@QueryParam("whitelistedOnly") @DefaultValue("false") final boolean whitelistedOnly) {
requireAny("READ_CICBUREAU", "READ_CICALERT");
return gson.toJson(configService.listBureaus(whitelistedOnly));
}
@GET
@Path("bureaus/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String bureau(@PathParam("id") final Long id) {
require("READ_CICBUREAU");
return gson.toJson(configService.getBureau(id));
}
@PUT
@Path("bureaus/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateBureau(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
return gson.toJson(configService.updateBureau(id, json(body), user));
}
@PUT
@Path("bureaus/{id}/whitelist")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String whitelist(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
final JsonObject in = json(body);
final boolean whitelisted = in.has("whitelisted") && !in.get("whitelisted").isJsonNull() && in.get("whitelisted").getAsBoolean();
return gson.toJson(configService.setWhitelisted(id, whitelisted, user));
}
@POST
@Path("bureaus/{id}/test-connection")
@Produces(MediaType.APPLICATION_JSON)
public String testConnection(@PathParam("id") final Long id) {
require("UPDATE_CICBUREAU");
return gson.toJson(configService.testConnection(id));
}
// ── API mapping ───────────────────────────────────────────────────────────
@GET
@Path("bureaus/{id}/endpoints/{type}")
@Produces(MediaType.APPLICATION_JSON)
public String endpoint(@PathParam("id") final Long id, @PathParam("type") final String type) {
require("READ_CICBUREAU");
return gson.toJson(configService.getEndpoint(id, type));
}
@PUT
@Path("bureaus/{id}/endpoints/{type}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String saveEndpoint(@PathParam("id") final Long id, @PathParam("type") final String type, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
return gson.toJson(configService.saveEndpoint(id, type, json(body), user));
}
@POST
@Path("bureaus/{id}/preview")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(alertService.preview(id, json(body)));
}
@POST
@Path("bureaus/{id}/parse-test")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String parseTest(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(alertService.parseTest(id, json(body)));
}
// ── Fetch / import ────────────────────────────────────────────────────────
@POST
@Path("bureaus/{id}/fetch")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String fetch(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
final JsonObject in = json(body);
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
}
@POST
@Path("bureaus/{id}/import")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String importFile(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
return gson.toJson(alertService.importFile(id, json(body), user));
}
@GET
@Path("fetch-runs")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
}
@GET
@Path("fetch-runs/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRun(@PathParam("id") final Long id) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRun(id, true));
}
// ── Alerts ────────────────────────────────────────────────────────────────
@GET
@Path("dashboard")
@Produces(MediaType.APPLICATION_JSON)
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
require("READ_CICALERT");
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
}
@GET
@Path("alerts")
@Produces(MediaType.APPLICATION_JSON)
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
require("READ_CICALERT");
final JsonObject f = new JsonObject();
f.addProperty("status", status);
f.addProperty("match", match);
f.addProperty("clientId", clientId);
f.addProperty("bureauId", bureauId);
f.addProperty("officeId", officeId);
f.addProperty("loanType", loanType);
f.addProperty("institutionType", institutionType);
f.addProperty("fromDate", fromDate);
f.addProperty("toDate", toDate);
f.addProperty("minAmount", minAmount);
f.addProperty("search", search);
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
}
@GET
@Path("alerts/options")
@Produces(MediaType.APPLICATION_JSON)
public String alertOptions() {
require("READ_CICALERT");
return gson.toJson(alertService.filterOptions());
}
@GET
@Path("alerts/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String alert(@PathParam("id") final Long id) {
require("READ_CICALERT");
return gson.toJson(alertService.get(id));
}
@PUT
@Path("alerts/{id}/status")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateStatus(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.updateStatus(id, json(body), user));
}
@PUT
@Path("alerts/{id}/client")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String linkClient(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.linkClient(id, json(body), user));
}
@POST
@Path("alerts/mark-seen")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String markSeen(final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.markSeen(json(body), user));
}
// ── helpers ──────────────────────────────────────────────────────────────
private AppUser require(final String permission) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo(permission);
return user;
}
private AppUser requireAny(final String... permissions) {
final AppUser user = context.authenticatedUser();
if (user.hasAnyPermission("ALL_FUNCTIONS") || user.hasAnyPermission(permissions)) {
return user;
}
throw new NoAuthorizationException("User has no authority to " + String.join(" / ", permissions));
}
private static JsonObject json(final String body) {
if (StringUtils.isBlank(body)) {
return new JsonObject();
}
final JsonElement e = JsonParser.parseString(body);
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
}
private static String text(final JsonObject in, final String key) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
}
private static int clamp(final int limit) {
return Math.max(1, Math.min(200, limit));
}
}
@@ -0,0 +1,89 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CicAlertScheduler {
private final FineractProperties fineractProperties;
private final TenantDetailsService tenantDetailsService;
private final BusinessDateReadPlatformService businessDateReadPlatformService;
private final JdbcTemplate jdbcTemplate;
private final CicAlertService alertService;
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
public void fetchDueAlerts() {
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
return;
}
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
try {
ThreadLocalContextUtil.setTenant(tenant);
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
""");
final long now = Instant.now().toEpochMilli();
for (final Map<String, Object> b : due) {
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
final Object last = b.get("last_fetch_on");
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
continue;
}
try {
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
} catch (final RuntimeException e) {
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
e.getMessage());
}
}
} catch (final RuntimeException e) {
// A tenant whose database does not have the CIC tables yet.
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
} finally {
ThreadLocalContextUtil.reset();
}
}
}
}
@@ -0,0 +1,215 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonPrimitive;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Everything the API Mapping screen can choose from for the ENQUIRY_ALERT call, and the transforms applied when a
* value is written into the bureau request or read back from the bureau response.
*/
public final class CicCatalog {
private CicCatalog() {}
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
static {
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
CBS_FIELDS.put("fetch.date", "Request date (business date)");
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
CBS_FIELDS.put("bureau.username", "Bureau user id");
}
/**
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
* response; "alerts" points at the list, and every other field is read from each item of that list.
*/
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
static {
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
RESULT_FIELDS.put("pan", "Customer PAN");
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
RESULT_FIELDS.put("customerName", "Customer name");
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
RESULT_FIELDS.put("loanType", "Loan type applied for");
RESULT_FIELDS.put("amount", "Loan amount applied for");
RESULT_FIELDS.put("purpose", "Enquiry purpose");
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
}
/** Result fields read from the root of the response, not from each alert. */
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
static {
TRANSFORMS.put("NONE", "As is");
TRANSFORMS.put("UPPER", "UPPER CASE");
TRANSFORMS.put("LOWER", "lower case");
TRANSFORMS.put("TRIM", "Trim spaces");
TRANSFORMS.put("DIGITS_ONLY", "Digits only");
TRANSFORMS.put("DATE_DDMMYYYY", "Date ddMMyyyy");
TRANSFORMS.put("DATE_DD_MM_YYYY", "Date dd-MM-yyyy");
TRANSFORMS.put("DATE_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
TRANSFORMS.put("NUMBER", "Number");
TRANSFORMS.put("BOOLEAN", "true / false");
}
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
if (raw == null) {
return null;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = String.valueOf(raw).trim();
if (s.isEmpty()) {
return null;
}
if (DATE_PATTERNS.containsKey(t)) {
try {
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
} catch (final RuntimeException e) {
return new JsonPrimitive(s);
}
}
switch (t) {
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
case "TRIM":
return new JsonPrimitive(s.replaceAll("\\s+", " "));
case "DIGITS_ONLY": {
final String digits = s.replaceAll("\\D", "");
return digits.isEmpty() ? null : new JsonPrimitive(digits);
}
case "AMOUNT_INTEGER":
return new JsonPrimitive(new BigDecimal(s).setScale(0, RoundingMode.HALF_UP).toBigInteger());
case "NUMBER":
return new JsonPrimitive(new BigDecimal(s).stripTrailingZeros());
case "BOOLEAN":
return new JsonPrimitive(Boolean.parseBoolean(s) || "1".equals(s) || "Y".equalsIgnoreCase(s));
default:
return new JsonPrimitive(s);
}
}
/**
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
*/
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
return value;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = value.getAsString().trim();
try {
if (DATE_PATTERNS.containsKey(t)) {
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
}
switch (t) {
case "NUMBER":
case "AMOUNT_INTEGER": {
final String numeric = s.replaceAll("[^0-9.\\-]", "");
return numeric.isEmpty() ? null : new JsonPrimitive(new BigDecimal(numeric));
}
case "BOOLEAN":
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s));
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
case "TRIM":
return new JsonPrimitive(s.replaceAll("\\s+", " "));
case "DIGITS_ONLY":
return new JsonPrimitive(s.replaceAll("\\D", ""));
default:
return value;
}
} catch (final RuntimeException e) {
return value;
}
}
/** Parses a bureau date in the given pattern, falling back to ISO. */
public static LocalDate parseDate(final String s, final String pattern) {
final String v = s.trim();
try {
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
} catch (final RuntimeException e) {
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
}
}
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
public static String mask(final String value) {
if (value == null || value.length() <= 4) {
return value == null ? null : "****";
}
final int keepStart = Math.min(2, value.length() / 4);
final int keepEnd = Math.min(4, value.length() / 3);
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
}
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
public static String maskAadhaar(final String value) {
if (value == null) {
return null;
}
final String digits = value.replaceAll("\\D", "");
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
}
}
@@ -0,0 +1,463 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The bank's bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
*/
@Service
@RequiredArgsConstructor
public class CicConfigService {
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
private final JdbcTemplate jdbcTemplate;
private final CicHttpGateway gateway;
// ── Bureaus ───────────────────────────────────────────────────────────────
/** Full row including the encrypted secret - internal use only, never returned by the API. */
public Map<String, Object> bureauRow(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
}
return rows.get(0);
}
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
out.add(publicView(row));
}
return out;
}
public Map<String, Object> getBureau(final Long id) {
return publicView(bureauRow(id));
}
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
private Map<String, Object> publicView(final Map<String, Object> row) {
final Map<String, Object> v = new LinkedHashMap<>();
final Long id = ((Number) row.get("id")).longValue();
v.put("id", id);
v.put("code", row.get("code"));
v.put("name", row.get("name"));
v.put("description", row.get("description"));
v.put("whitelisted", row.get("whitelisted"));
v.put("isDefault", row.get("is_default"));
v.put("environment", row.get("environment"));
v.put("baseUrl", row.get("base_url"));
v.put("memberCode", row.get("member_code"));
v.put("authType", row.get("auth_type"));
v.put("username", row.get("username"));
v.put("apiKeyHeader", row.get("api_key_header"));
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
v.put("tokenUrl", row.get("token_url"));
v.put("clientId", row.get("client_id"));
v.put("timeoutMs", row.get("timeout_ms"));
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
v.put("alertLookbackDays", row.get("alert_lookback_days"));
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
v.put("lastFetchStatus", row.get("last_fetch_status"));
v.put("lastFetchMessage", row.get("last_fetch_message"));
v.put("lastTestStatus", row.get("last_test_status"));
v.put("lastTestMessage", row.get("last_test_message"));
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
v.put("activeEndpoints", jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
Integer.class, id));
return v;
}
@Transactional
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
final String environment = upper(text(in, "environment", (String) row.get("environment")));
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
}
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
}
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
}
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
}
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
if (timeoutMs < 1000 || timeoutMs > 300000) {
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
}
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
if (interval < 15 || interval > 1440) {
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
}
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
if (lookback < 1 || lookback > 90) {
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
}
String secretEnc = (String) row.get("secret_enc");
if (in.has("clearSecret") && in.get("clearSecret").getAsBoolean()) {
secretEnc = null;
} else if (in.has("secret") && !in.get("secret").isJsonNull() && !in.get("secret").getAsString().isBlank()) {
secretEnc = gateway.encrypt(in.get("secret").getAsString());
}
final boolean makeDefault = in.has("isDefault") && !in.get("isDefault").isJsonNull() && in.get("isDefault").getAsBoolean();
if (makeDefault) {
jdbcTemplate.update("UPDATE m_cic_bureau SET is_default = FALSE WHERE id <> ?", id);
}
jdbcTemplate.update("""
UPDATE m_cic_bureau SET name = ?, description = ?, environment = ?, base_url = ?, member_code = ?, auth_type = ?,
username = ?, api_key_header = ?, secret_enc = ?, token_url = ?, client_id = ?, timeout_ms = ?,
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
updated_on = ?
WHERE id = ?
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
trimToNull(text(in, "username", (String) row.get("username"))),
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
gateway.evictToken(id);
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
assertReadyForLive(bureauRow(id));
}
return getBureau(id);
}
@Transactional
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
if (whitelisted) {
assertReadyForLive(row);
}
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
user.getId(), Timestamp.from(Instant.now()), id);
return getBureau(id);
}
/** What a bureau needs before alerts may be fetched from it. */
public void assertReadyForLive(final Map<String, Object> row) {
final String code = (String) row.get("code");
final Integer endpoints = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
if (endpoints == null || endpoints == 0) {
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
}
if ("SIMULATOR".equals(row.get("environment"))) {
return;
}
if (StringUtils.isBlank((String) row.get("base_url"))) {
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
}
final String auth = (String) row.get("auth_type");
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
if (!"NONE".equals(auth) && !hasSecret) {
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
}
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
}
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
}
}
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
public Map<String, Object> testConnection(final Long id) {
final Map<String, Object> row = bureauRow(id);
String status;
String message;
if ("SIMULATOR".equals(row.get("environment"))) {
status = "OK";
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
status = "FAILED";
message = "No base URL configured.";
} else {
try {
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
gateway.evictToken(id);
gateway.oauthToken(row);
}
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
if (r.status() == 0) {
status = "FAILED";
message = "Not reachable: " + r.error();
} else if (r.status() == 401 || r.status() == 403) {
status = "FAILED";
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
} else {
status = "OK";
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
}
} catch (final IOException | RuntimeException e) {
status = "FAILED";
message = e.getMessage();
}
}
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("status", status);
res.put("message", message);
return res;
}
// ── Endpoints and mappings ────────────────────────────────────────────────
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
return rows.isEmpty() ? null : rows.get(0);
}
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
return jdbcTemplate.queryForList(
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
}
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final Map<String, Object> e = endpointRow(bureauId, t);
final Map<String, Object> v = new LinkedHashMap<>();
v.put("bureauId", bureauId);
v.put("endpointType", t);
v.put("exists", e != null);
v.put("id", e == null ? null : e.get("id"));
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
v.put("path", e == null ? null : e.get("path"));
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
return v;
}
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> m : rows) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", m.get("id"));
v.put("sourceField", m.get("source_field"));
v.put("targetField", m.get("target_field"));
v.put("transform", m.get("transform"));
v.put("defaultValue", m.get("default_value"));
v.put("mandatory", m.get("is_mandatory"));
v.put("sortOrder", m.get("sort_order"));
out.add(v);
}
return out;
}
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
@Transactional
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
final Map<String, Object> bureau = bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
if (!CicCatalog.HTTP_METHODS.contains(method)) {
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
}
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
requireJsonObject(headersJson, "Headers");
requireJsonObject(baseBody, "Base request body");
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
if (!h.getValue().isJsonPrimitive()) {
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
}
}
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
final List<Object[]> rows = new ArrayList<>();
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
}
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
throw fail("error.msg.cic.no.alert.list",
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
}
Map<String, Object> existing = endpointRow(bureauId, t);
final Timestamp now = Timestamp.from(Instant.now());
if (existing == null) {
jdbcTemplate.update("""
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
is_active, updated_by, updated_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", bureauId, t, method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now);
existing = endpointRow(bureauId, t);
} else {
jdbcTemplate.update("""
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
updated_by = ?, updated_on = ?
WHERE id = ?
""", method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now, existing.get("id"));
}
final Long endpointId = ((Number) existing.get("id")).longValue();
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
for (final Object[] r : rows) {
jdbcTemplate.update("""
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
}
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
assertReadyForLive(bureauRow(bureauId));
}
return getEndpoint(bureauId, t);
}
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
int order = 0;
for (final JsonElement el : array) {
final JsonObject m = el.getAsJsonObject();
order += 10;
final String source = trimToNull(text(m, "sourceField", null));
final String target = trimToNull(text(m, "targetField", null));
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
final String defaultValue = trimToNull(text(m, "defaultValue", null));
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
final String label = direction + " row " + (order / 10);
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
}
if (target == null) {
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
}
if ("REQUEST".equals(direction)) {
if (source == null && defaultValue == null) {
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
}
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
}
if (!CicJsonPath.isValid(target)) {
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
}
} else {
if (source == null || !CicJsonPath.isValid(source)) {
throw fail("error.msg.cic.mapping.path.invalid",
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
}
if (!RESULT_KEY.matcher(target).matches()) {
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
}
}
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
}
}
// ── helpers ──────────────────────────────────────────────────────────────
private static void requireJsonObject(final String text, final String label) {
try {
if (!JsonParser.parseString(text).isJsonObject()) {
throw new IllegalArgumentException();
}
} catch (final RuntimeException e) {
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
}
}
static String text(final JsonObject in, final String key, final String fallback) {
if (in == null || !in.has(key)) {
return fallback;
}
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
}
private static int intOf(final JsonObject in, final String key, final int fallback) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
}
private static String upper(final String s) {
return s == null ? null : s.trim().toUpperCase();
}
private static String trimToNull(final String s) {
return StringUtils.trimToNull(s);
}
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -0,0 +1,194 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.FormBody;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
import org.springframework.stereotype.Service;
/**
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
* here, for the call itself.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CicHttpGateway {
private static final int MAX_BODY_CHARS = 2_000_000;
private final DatabasePasswordEncryptor encryptor;
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
private record CachedToken(String token, Instant expiresAt) {}
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
public record HttpResult(int status, String body, long durationMs, String error) {
public boolean ok() {
return status >= 200 && status < 300;
}
}
public String encrypt(final String plain) {
return plain == null ? null : encryptor.encrypt(plain);
}
private String secretOf(final Map<String, Object> bureau) {
final Object enc = bureau.get("secret_enc");
if (enc == null || enc.toString().isBlank()) {
return null;
}
try {
return encryptor.decrypt(enc.toString());
} catch (final RuntimeException e) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
+ " Re-enter it on the bureau configuration.");
}
}
public static String joinUrl(final String baseUrl, final String path) {
if (StringUtils.startsWithAny(path, "http://", "https://")) {
return path;
}
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
if (StringUtils.isBlank(path)) {
return base;
}
return base + (path.startsWith("/") ? path : "/" + path);
}
private OkHttpClient client(final Map<String, Object> bureau) {
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
}
/** Sends one request to the bureau with its configured authentication. */
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
final String contentType, final String body) {
final long started = System.currentTimeMillis();
try {
final Request.Builder rb = new Request.Builder().url(url);
headers.forEach((k, v) -> {
if (StringUtils.isNotBlank(k) && v != null) {
rb.header(k, v);
}
});
applyAuth(bureau, rb);
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
if ("GET".equals(m)) {
rb.get();
} else {
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
}
try (Response response = client(bureau).newCall(rb.build()).execute()) {
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
}
} catch (final IOException | IllegalArgumentException e) {
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private static String read(final ResponseBody body) throws IOException {
if (body == null) {
return null;
}
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
}
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
final String username = (String) bureau.get("username");
switch (auth) {
case "BASIC" -> {
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
}
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
StringUtils.defaultString(secretOf(bureau)));
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
default -> {
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
}
}
}
/** OAuth2 client-credentials token, cached until a minute before it expires. */
public String oauthToken(final Map<String, Object> bureau) throws IOException {
final Long id = ((Number) bureau.get("id")).longValue();
final CachedToken cached = tokens.get(id);
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
return cached.token();
}
final String tokenUrl = (String) bureau.get("token_url");
if (StringUtils.isBlank(tokenUrl)) {
throw new IOException("OAuth2 token URL is not configured");
}
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
final String text = read(response.body());
if (!response.isSuccessful()) {
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
}
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
if (!obj.has("access_token")) {
throw new IOException("Token endpoint response has no access_token");
}
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
final String token = obj.get("access_token").getAsString();
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
return token;
}
}
/** Forget a cached token (after the bureau's credentials change). */
public void evictToken(final Long bureauId) {
tokens.remove(bureauId);
}
}
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
* way; reading returns null for anything absent.
*/
public final class CicJsonPath {
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
private CicJsonPath() {}
/** One step of a path: an object key, optionally followed by array indexes. */
private record Step(String key, List<Integer> indexes) {}
private static List<Step> parse(final String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("Empty JSON path");
}
final List<Step> steps = new ArrayList<>();
for (final String raw : path.trim().split("\\.")) {
final Matcher m = SEGMENT.matcher(raw);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
}
final List<Integer> indexes = new ArrayList<>();
final Matcher im = INDEX.matcher(m.group(2));
while (im.find()) {
indexes.add(Integer.parseInt(im.group(1)));
}
steps.add(new Step(m.group(1), indexes));
}
return steps;
}
/** Whether the path is syntactically valid. */
public static boolean isValid(final String path) {
try {
parse(path);
return true;
} catch (final IllegalArgumentException e) {
return false;
}
}
public static JsonElement get(final JsonElement root, final String path) {
JsonElement current = root;
for (final Step step : parse(path)) {
if (!step.key().isEmpty()) {
if (current == null || !current.isJsonObject()) {
return null;
}
current = current.getAsJsonObject().get(step.key());
}
for (final Integer index : step.indexes()) {
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
return null;
}
current = current.getAsJsonArray().get(index);
}
}
return current == null || current.isJsonNull() ? null : current;
}
public static void set(final JsonObject root, final String path, final JsonElement value) {
final List<Step> steps = parse(path);
JsonElement current = root;
for (int s = 0; s < steps.size(); s++) {
final Step step = steps.get(s);
final boolean lastStep = s == steps.size() - 1;
if (!step.key().isEmpty()) {
final JsonObject obj = current.getAsJsonObject();
if (lastStep && step.indexes().isEmpty()) {
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = obj.get(step.key());
if (next == null || next.isJsonNull()) {
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
obj.add(step.key(), next);
}
current = next;
}
for (int i = 0; i < step.indexes().size(); i++) {
final int index = step.indexes().get(i);
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
final JsonArray array = current.getAsJsonArray();
while (array.size() <= index) {
array.add(JsonNull.INSTANCE);
}
if (lastIndex) {
array.set(index, value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = array.get(index);
if (next == null || next.isJsonNull()) {
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
array.set(index, next);
}
current = next;
}
}
}
}
@@ -0,0 +1,153 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Random;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
*/
@Component
@RequiredArgsConstructor
public class CicSimulator {
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
final String code = String.valueOf(bureau.get("code"));
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
ORDER BY k.id DESC LIMIT 1) AS pan,
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
FROM m_client c
WHERE c.status_enum = 300
ORDER BY c.id
LIMIT 300
""");
final JsonArray alerts = new JsonArray();
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
for (int d = 0; d <= days; d++) {
final LocalDate day = from.plusDays(d);
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
int seq = 0;
for (int i = 0; i < count; i++) {
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
}
if (r.nextInt(8) == 0) {
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
r, null));
}
if (!clients.isEmpty() && r.nextInt(10) == 0) {
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
}
}
final JsonObject header = new JsonObject();
header.addProperty("status", "SUCCESS");
header.addProperty("referenceNumber", reference);
header.addProperty("bureau", code);
header.addProperty("fromDate", from.format(DDMMYYYY));
header.addProperty("toDate", to.format(DDMMYYYY));
final JsonObject data = new JsonObject();
data.addProperty("totalAlerts", alerts.size());
data.add("alerts", alerts);
final JsonObject root = new JsonObject();
root.add("responseHeader", header);
root.add("data", data);
return root;
}
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
final String forcedMemberCode) {
final JsonObject consumer = new JsonObject();
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
final Object pan = client.get("pan");
final Object aadhaar = client.get("aadhaar");
if (pan != null && !pan.toString().isBlank()) {
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
} else if (client.get("account_no") != null) {
consumer.addProperty("memberReference", client.get("account_no").toString());
}
if (client.get("date_of_birth") != null) {
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
}
final int lenderIdx = r.nextInt(LENDERS.length);
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
final int min = (Integer) type[1];
final int max = (Integer) type[2];
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
final JsonObject enquiry = new JsonObject();
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
enquiry.addProperty("date", day.format(DDMMYYYY));
enquiry.addProperty("productType", (String) type[0]);
enquiry.addProperty("amount", amount);
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
final JsonObject a = new JsonObject();
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
a.add("consumer", consumer);
a.add("enquiry", enquiry);
return a;
}
private static String lastFour(final String value) {
final String digits = value.replaceAll("\\D", "");
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
}
}
@@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
@@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource {
}
}
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
validateFixedDepositLegs(creditEntries, requestType);
// Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
@@ -491,6 +498,10 @@ public class ClerkDisbursementRequestApiResource {
rejectRecurringDepositLeg(entry, entryType);
}
}
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
// same FD while this one waited, and a refusal now still leaves the request PENDING.
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request.
@@ -773,6 +784,12 @@ public class ClerkDisbursementRequestApiResource {
}
} // end else (disbursement/collection mode)
// A funded FD's maturity date and amount were projected off its principal with no money in it;
// the direct credit above does not recompute them, so do it now that the money has landed.
for (final Long fdId : fundedFixedDeposits) {
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
}
} catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing.
@@ -1223,6 +1240,70 @@ public class ClerkDisbursementRequestApiResource {
}
}
/**
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
* <ul>
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
* the bank's own money and has no customer to compare.</li>
* </ul>
*
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
*/
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
final Set<Long> fdIds = new LinkedHashSet<>();
Long firstFdId = null;
Long fdClientId = null;
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
continue;
}
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
+ " through its maturity or premature closure.");
}
if (!fdIds.add(accountId)) {
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
+ " with its full principal in one entry.");
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
if (firstFdId == null) {
firstFdId = accountId;
fdClientId = clientId;
} else if (!Objects.equals(fdClientId, clientId)) {
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
+ " only. Raise a separate request for each customer.");
}
}
if (fdIds.isEmpty()) {
return fdIds;
}
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
"CURRENT".equals(type), amount);
}
return fdIds;
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -48,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -56,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -84,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
@@ -110,13 +113,28 @@ public class ClerkWithdrawalRequestApiResource {
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
final boolean isGl = "GL".equalsIgnoreCase(accountType);
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required");
}
if (savingsAccountId == null) {
if (isGl) {
validateGlWithdrawalAccounts(glAccountId, contraGlId);
} else if (savingsAccountId == null) {
throw apiError(400, "Savings account id is required for a withdrawal request");
}
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero");
}
@@ -124,7 +142,9 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
if (!isGl) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -132,8 +152,9 @@ public class ClerkWithdrawalRequestApiResource {
INSERT INTO m_clerk_withdrawal_request
(account_no, account_type, savings_account_id, client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
gl_account_id, contra_gl_id, office_id, currency_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -142,7 +163,9 @@ public class ClerkWithdrawalRequestApiResource {
txnDate != null ? Date.valueOf(txnDate) : null,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now())
denominationData, activityLog, Timestamp.from(Instant.now()),
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
isGl ? (currencyCode != null ? currencyCode : "INR") : null
);
// Globally unique voucher number from the central m_voucher register
@@ -198,7 +221,8 @@ public class ClerkWithdrawalRequestApiResource {
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
+ " gl_account_id, contra_gl_id, office_id, currency_code"
+ " FROM m_clerk_withdrawal_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -252,7 +276,8 @@ public class ClerkWithdrawalRequestApiResource {
payment_type_name, description, tok_no, txn_date, status,
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -276,7 +301,8 @@ public class ClerkWithdrawalRequestApiResource {
txn_date, status, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -295,7 +321,8 @@ public class ClerkWithdrawalRequestApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
amount, payment_type_id, payment_type_name, description, tok_no, status,
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
ORDER BY submitted_at DESC LIMIT 1
@@ -455,13 +482,16 @@ public class ClerkWithdrawalRequestApiResource {
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
}
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
final Long savingsAccountId = record.get("savings_account_id") != null
? ((Number) record.get("savings_account_id")).longValue() : null;
// Checked again on approval, not only on submission: a request can sit in the queue for days,
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
// below refuses a dormant account anyway, but that failure would surface to the manager as a
// rejected transaction after the approval was recorded; this says why, before anything moves.
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
if (!isGlRequest) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
@@ -469,7 +499,11 @@ public class ClerkWithdrawalRequestApiResource {
final String description = (String) record.get("description");
String txnId = null;
if (savingsAccountId != null) {
if (isGlRequest) {
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
} else if (savingsAccountId != null) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
@@ -477,14 +511,18 @@ public class ClerkWithdrawalRequestApiResource {
}
// Link voucher to the created savings txn and stamp voucher_no on it
if (txnId != null) {
if (txnId != null && !isGlRequest) {
voucherService.linkSavingsTransaction(
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
}
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
if (isGlRequest) {
addGlWithdrawalVoucherLines(record, amount);
} else {
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -689,8 +727,8 @@ public class ClerkWithdrawalRequestApiResource {
if (denomMap == null || denomMap.size() == 0) return;
final Object txnIdObj = record.get("txn_id");
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
if (txnId == null) return; // no savings txn linked — cannot record denomination
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
if (txnId == null) return; // no txn linked — cannot record denomination
final Object saObj = record.get("savings_account_id");
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
@@ -793,6 +831,117 @@ public class ClerkWithdrawalRequestApiResource {
return rows.isEmpty() ? null : rows.get(0);
}
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
/**
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
* the id of its own journal entry in the cashier's position and closing report.
*/
private Long parseTxnKey(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Long.valueOf(raw);
} catch (final NumberFormatException ignored) {
try {
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
return Long.parseUnsignedLong(hex, 16);
} catch (final NumberFormatException ignored2) {
return null;
}
}
}
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
* GL it is paid from must be one of the branch's mapped cash accounts. */
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
if (glAccountId == null || contraGlId == null) {
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
}
final List<Long> cashGlIds = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
if (!cashGlIds.contains(contraGlId)) {
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
}
if (cashGlIds.contains(glAccountId)) {
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
}
}
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
* switched off for manual postings). Returns the journal entry's transaction id. */
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
debit.addProperty("amount", amount);
final JsonObject credit = new JsonObject();
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
credit.addProperty("amount", amount);
final JsonArray debits = new JsonArray();
debits.add(debit);
final JsonArray credits = new JsonArray();
credits.add(credit);
final Object office = record.get("office_id");
final Object currency = record.get("currency_code");
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
payload.addProperty("transactionDate", date);
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
if (note != null && !note.isBlank()) {
payload.addProperty("comments", note);
}
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
payload.add("credits", credits);
payload.add("debits", debits);
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
// The request raised its own voucher at submission; the journal command must not raise a second.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getTransactionId();
}
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final Long glId = toLong(record.get("gl_account_id"));
final Long contraId = toLong(record.get("contra_gl_id"));
final Map<String, Object> gl = glAccountForVoucher(glId);
final Map<String, Object> contra = glAccountForVoucher(contraId);
voucherService.addLines(voucherNo, List.of(
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
}
}
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
}
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
.orElse(Map.of());
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -800,7 +949,8 @@ public class ClerkWithdrawalRequestApiResource {
txn_date, status, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -38,14 +38,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
private static final Set<String> VALID_ACCOUNT_TYPES = Set.of("SAVINGS", "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT");
// A customer's PAN lives on their KYC record (m_client_kyc, one row per client) and, for some, as a
// "PAN Card" ID proof. The KYC record is what the client screens and cash screens read, so it wins;
// the ID proof is the fallback. Reading only the ID proof showed "PAN -" for customers whose PAN was
// on file, and a search by PAN number found none of them.
private static final String BASE_SELECT = """
SELECT DISTINCT c.id AS client_id, c.display_name, c.mobile_no, o.name AS office_name,
acct.account_no, acct.account_type,
pan.document_key AS pan_number, aad.document_key AS aadhaar_number,
COALESCE(NULLIF(kyc.pan_number, ''), pan.document_key) AS pan_number, aad.document_key AS aadhaar_number,
addr.address_line_1, addr.address_line_2, addr.town_village, addr.city,
addr.county_district, addr.postal_code
FROM m_client c
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc kyc ON kyc.client_id = c.id
LEFT JOIN m_client_identifier pan ON pan.client_id = c.id AND pan.document_type_id = (
SELECT id FROM m_code_value WHERE code_value = 'PAN Card'
AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType'))
@@ -100,7 +105,8 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
params.add(aadhaarNumber.trim());
}
if (hasPan) {
sql.append(" AND UPPER(pan.document_key) = UPPER(?) ");
sql.append(" AND (UPPER(kyc.pan_number) = UPPER(?) OR UPPER(pan.document_key) = UPPER(?)) ");
params.add(panNumber.trim());
params.add(panNumber.trim());
}
if (hasAddress) {
@@ -58,6 +58,7 @@ import org.apache.fineract.infrastructure.core.api.jersey.Pagination;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.DateFormat;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -737,6 +738,10 @@ public class LoanTransactionsApiResource {
commandRequest = builder.buyDownFeeAdjustment(resolvedLoanId, resolvedTransactionId).build();
} else if (CommandParameterUtil.is(commandParam, INTEREST_REFUND_COMMAND_VALUE)) {
commandRequest = builder.manualInterestRefund(resolvedLoanId, resolvedTransactionId).build();
} else if (apiRequestBodyAsJson != null
&& apiRequestBodyAsJson.matches("(?s).*\"transactionAmount\"\\s*:\\s*\"?0(\\.0*)?\"?\\s*[,}].*")) {
throw new PlatformDataIntegrityException("error.msg.loan.transaction.undo.not.allowed",
"Undo of a loan transaction is not allowed.");
} else { // Default to adjust the Loan Transaction
commandRequest = builder.adjustTransaction(resolvedLoanId, resolvedTransactionId).build();
}
@@ -43,6 +43,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -140,6 +141,11 @@ public class CurrentAccountTransactionsApiResource {
@QueryParam("command") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (is(commandParam, "undo") || is(commandParam, "reverse")) {
throw new PlatformDataIntegrityException("error.msg.currentaccount.transaction.undo.not.allowed",
"Undo or reverse of a current account transaction is not allowed.");
}
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
CommandProcessingResult result = null;
@@ -40,6 +40,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -148,6 +149,11 @@ public class FixedDepositAccountTransactionsApiResource {
@PathParam("transactionId") final Long transactionId, @QueryParam("command") final String commandParam,
final String apiRequestBodyAsJson) {
if (is(commandParam, DepositsApiConstants.COMMAND_UNDO_TRANSACTION)) {
throw new PlatformDataIntegrityException("error.msg.fixeddeposit.transaction.undo.not.allowed",
"Undo of a fixed deposit transaction is not allowed.");
}
String jsonApiRequest = apiRequestBodyAsJson;
if (StringUtils.isBlank(jsonApiRequest)) {
jsonApiRequest = "{}";
@@ -47,6 +47,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -202,6 +203,11 @@ public class RecurringDepositAccountTransactionsApiResource {
@QueryParam("command") @Parameter(description = "command") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (is(commandParam, DepositsApiConstants.COMMAND_UNDO_TRANSACTION)) {
throw new PlatformDataIntegrityException("error.msg.recurringdeposit.transaction.undo.not.allowed",
"Undo of a recurring deposit transaction is not allowed.");
}
String jsonApiRequest = apiRequestBodyAsJson;
if (StringUtils.isBlank(jsonApiRequest)) {
jsonApiRequest = "{}";
@@ -48,6 +48,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -218,6 +219,12 @@ public class SavingsAccountTransactionsApiResource {
public String adjustTransaction(@PathParam("savingsId") final Long savingsId, @PathParam("transactionId") final Long transactionId,
@QueryParam("command") final String commandParam, final String apiRequestBodyAsJson) {
if (is(commandParam, SavingsApiConstants.COMMAND_UNDO_TRANSACTION)
|| is(commandParam, SavingsApiConstants.COMMAND_REVERSE_TRANSACTION)) {
throw new PlatformDataIntegrityException("error.msg.savings.transaction.undo.not.allowed",
"Undo or reverse of a savings transaction is not allowed.");
}
String jsonApiRequest = apiRequestBodyAsJson;
if (StringUtils.isBlank(jsonApiRequest)) {
jsonApiRequest = "{}";
@@ -392,6 +392,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
// An FD is funded once, with exactly its principal (see FixedDepositFundingValidator).
new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed();
@@ -422,6 +425,20 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public void recalculateFixedDepositMaturity(final Long savingsId) {
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@Transactional
@Override
public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) {
@@ -48,7 +48,6 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent;
@@ -184,16 +183,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy,
DepositAccountType.FIXED_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Fixed Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Fixed Deposits on the same product, even while an earlier one is
// still active, so there is deliberately no one-active-account-per-product check here.
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
@@ -269,16 +260,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command,
submittedBy, DepositAccountType.RECURRING_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Recurring Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Recurring Deposits on the same product, even while an earlier one
// is still active, so there is deliberately no one-active-account-per-product check here.
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
@@ -0,0 +1,238 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit
* command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer,
* Fund Transfer and Disbursement requests, which credit the account directly.
*
* <p>
* An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit
* would silently change the principal the maturity was agreed on; anything more goes into a new FD (several
* FDs on the same product are allowed). Money never leaves an FD through these paths either - only through
* its maturity or premature closure.
*
* <p>
* When the money comes from the customer's own Savings or Current account, that account must be able to pay
* it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them),
* and holding enough available balance once holds, liens, clearing holds and its minimum balance are set
* aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard.
*/
@Component
@RequiredArgsConstructor
public class FixedDepositFundingValidator {
/** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */
public static final int DEPOSIT_TYPE_SAVINGS = 100;
public static final int DEPOSIT_TYPE_FIXED = 200;
private static final int STATUS_ACTIVE = 300;
private static final int TXN_TYPE_DEPOSIT = 1;
/** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */
private static final int SUB_STATUS_BLOCK = 400;
private static final int SUB_STATUS_BLOCK_DEBIT = 600;
private final JdbcTemplate jdbcTemplate;
/** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */
public boolean isFixedDeposit(final Long savingsAccountId) {
if (savingsAccountId == null) {
return false;
}
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED);
return count != null && count > 0;
}
/**
* The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no
* money in it, and the amount must be exactly its principal.
*
* @return the FD's client id, for the source-account checks
*/
public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived,
t.deposit_amount, t.maturity_date
FROM m_savings_account s
LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id
WHERE s.id = ?
""", fdAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found.");
}
final Map<String, Object> fd = rows.get(0);
final String accountNo = (String) fd.get("account_no");
if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) {
throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit.");
}
if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.not.active",
"Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded.");
}
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (maturityDate != null && !businessDate.isBefore(maturityDate)) {
throw fail("error.msg.fd.funding.matured",
"Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded.");
}
final Integer deposits = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?"
+ " AND is_reversed = false",
Integer.class, fdAccountId, TXN_TYPE_DEPOSIT);
if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) {
throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo
+ " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money.");
}
final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount"));
if (principal == null || principal.signum() <= 0) {
throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set.");
}
if (amount == null || amount.compareTo(principal) != 0) {
throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString())
+ " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more.");
}
return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue();
}
/**
* The Savings or Current account an FD is being funded from.
*
* @param fdAccountId
* the FD being funded
* @param fdClientId
* its client, from {@link #validateFirstFunding}
* @param sourceAccountId
* m_savings_account id, or m_current_account id when {@code sourceIsCurrent}
*/
public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final BigDecimal amount) {
final List<Map<String, Object>> rows = sourceIsCurrent ? jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived,
total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance,
allow_overdraft, overdraft_limit, lockedin_until_date_derived
FROM m_current_account WHERE id = ?
""", sourceAccountId) : jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived,
on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing,
min_required_balance, enforce_min_required_balance, lockedin_until_date_derived
FROM m_savings_account WHERE id = ?
""", sourceAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found.");
}
final Map<String, Object> src = rows.get(0);
final String accountNo = (String) src.get("account_no");
if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) {
throw fail("error.msg.fd.funding.source.type",
"Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can.");
}
if (intOf(src.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active.");
}
final int subStatus = intOf(src.get("sub_status_enum"));
if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) {
throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze).");
}
final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) {
throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + ".");
}
final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue();
if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) {
throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo
+ " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account"
+ " or one they hold jointly.");
}
BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived")))
.subtract(amountOf(src.get("total_savings_amount_on_hold")));
if (!sourceIsCurrent) {
available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing")));
}
if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) {
available = available.subtract(amountOf(src.get("min_required_balance")));
}
if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) {
available = available.add(amountOf(src.get("overdraft_limit")));
}
if (amount == null || available.compareTo(amount) < 0) {
throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of "
+ available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than "
+ (amount == null ? "0" : amount.toPlainString()) + ".");
}
}
private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final Long sourceClientId) {
if (fdClientId == null) {
return false;
}
if (Objects.equals(fdClientId, sourceClientId)) {
return true;
}
// The FD's customer is a joint holder on the source account ...
final Integer onSource = sourceIsCurrent
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?",
Integer.class, sourceAccountId, fdClientId)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
sourceAccountId, fdClientId);
if (onSource != null && onSource > 0) {
return true;
}
// ... or the source account's customer is a joint holder on the FD.
if (sourceClientId == null) {
return false;
}
final Integer onFd = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
fdAccountId, sourceClientId);
return onFd != null && onFd > 0;
}
private static int intOf(final Object value) {
return value instanceof Number n ? n.intValue() : -1;
}
private static BigDecimal amountOf(final Object value) {
return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO;
}
private static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -0,0 +1,623 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.voucher.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the
* cashier's back.
*
* <ol>
* <li>The cashier picks the day's voucher and asks to <b>unpass</b> it (UNPASS_PENDING). Nothing moves.</li>
* <li>A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply
* waiting for its correction entry.</li>
* <li>The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number
* (CORRECTION_PENDING).</li>
* <li>A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is
* reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the
* original voucher is revoked and the cashier's denominations follow the deposit.</li>
* </ol>
*
* The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is
* approved, because the customer may have drawn the money in between.
*/
@Path("/v1/vouchers/corrections")
@Component
@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.")
@RequiredArgsConstructor
public class VoucherCorrectionApiResource {
public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION";
private static final String UNPASS_PENDING = "UNPASS_PENDING";
private static final String UNPASSED = "UNPASSED";
private static final String UNPASS_REJECTED = "UNPASS_REJECTED";
private static final String CORRECTION_PENDING = "CORRECTION_PENDING";
private static final String COMPLETED = "COMPLETED";
private static final String CLOSED = "CLOSED";
private static final int SAVINGS_STATUS_ACTIVE = 300;
private static final int DEPOSIT_TYPE_SAVINGS = 100;
private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final KycRestrictionGuard kycRestrictionGuard;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Today's vouchers the cashier may unpass ──────────────────────────────────
/** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */
@GET
@Path("eligible")
@Produces(MediaType.APPLICATION_JSON)
public String eligible(@QueryParam("search") final String search) {
requireUnpasser(this.context.authenticatedUser());
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
final List<Object> params = new ArrayList<>();
params.add("%/" + day + "/%");
final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL);
if (search != null && !search.isBlank()) {
sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)");
final String like = "%" + search.trim() + "%";
params.add(like);
params.add(like);
params.add(like);
}
sql.append(" ORDER BY v.id DESC");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
}
private static final String ELIGIBLE_SQL = """
SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt",
v.created_by_username AS "cashier", v.savings_txn_id AS "txnId",
t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo",
c.display_name AS "clientName", pt.value AS "paymentType"
FROM m_voucher v
JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id
JOIN m_savings_account sa ON sa.id = t.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE v.voucher_type = 'DEPOSIT'
AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION'
AND v.voucher_no LIKE ?
AND t.is_reversed = false
AND t.transaction_type_enum = 1
AND pt.is_cash_payment = true
AND sa.deposit_type_enum = 100
AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc
WHERE vc.original_voucher_no = v.voucher_no
AND vc.status NOT IN ('UNPASS_REJECTED'))
""";
// ── Step 1: cashier unpasses a voucher ───────────────────────────────────────
@POST
@Path("unpass")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String unpass(final String body) {
final AppUser user = this.context.authenticatedUser();
requireUnpasser(user);
final JsonElement root = fromJsonHelper.parse(body);
final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root);
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (voucherNo == null || voucherNo.isBlank()) {
throw apiError(400, "Voucher number is required");
}
if (reason == null || reason.isBlank()) {
throw apiError(400, "Reason is required");
}
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%",
voucherNo.trim());
if (rows.isEmpty()) {
throw apiError(409, "Voucher " + voucherNo
+ " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify.");
}
final Map<String, Object> v = rows.get(0);
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_voucher_correction
(original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name,
amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log)
VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?),
(SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?),
?,?,?,?,?,?)
RETURNING id
""", Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"),
v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(),
Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim()));
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING));
}
// ── Lists ────────────────────────────────────────────────────────────────────
/**
* Corrections by status. {@code status} may be a comma-separated list. The approval page asks for
* UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1");
final List<Object> params = new ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)");
params.add(user.getId());
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
}
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public String get(@PathParam("id") final Long id) {
this.context.authenticatedUser();
return gson.toJson(load(id));
}
// ── Step 2: manager approves / rejects the unpass ────────────────────────────
@POST
@Path("{id}/unpass/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approveUnpass(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, UNPASS_PENDING, "approved");
requireNotMaker(user, r.get("unpass_by_id"), "unpass request");
final int updated = jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?,
unpass_approved_at = ?, activity_log = ?
WHERE id = ? AND status = ?
""", UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING);
if (updated == 0) {
throw apiError(409, "This request was already actioned by another manager.");
}
// Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved.
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
}
@POST
@Path("{id}/unpass/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String rejectUnpass(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, UNPASS_PENDING, "rejected");
final String reason = requireReason(body);
jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED,
reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id);
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED));
}
// ── Step 3: cashier enters the correct account ───────────────────────────────
/** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */
@GET
@Path("account-lookup")
@Produces(MediaType.APPLICATION_JSON)
public String accountLookup(@QueryParam("accountNo") final String accountNo) {
this.context.authenticatedUser();
final Map<String, Object> account = findSavingsAccount(accountNo);
if (account == null) {
throw apiError(404, "No savings account found for '" + accountNo + "'.");
}
return gson.toJson(account);
}
@POST
@Path("{id}/correction")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submitCorrection(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
// The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it.
if (cashierRoleHelper.getCurrentCashierId() == null) {
throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier.");
}
final Map<String, Object> r = load(id);
requireStatus(r, UNPASSED, "corrected");
final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body));
final Map<String, Object> correct = validateCorrectAccount(r, accountNo);
checkWrongAccountBalance(r);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?,
correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?,
correction_at = ?, rejection_reason = NULL, activity_log = ?
WHERE id = ? AND status = ?
""", CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"),
user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED);
return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING));
}
// ── Step 4: manager approves / rejects the correction ────────────────────────
@POST
@Path("{id}/correction/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approveCorrection(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, CORRECTION_PENDING, "approved");
requireNotMaker(user, r.get("correction_by_id"), "correction");
// Both sides are checked again: a lot can happen between the cashier saving and the manager approving.
validateCorrectAccount(r, (String) r.get("correct_account_no"));
checkWrongAccountBalance(r);
final Long wrongId = toLong(r.get("wrong_savings_account_id"));
final Long correctId = toLong(r.get("correct_savings_account_id"));
final Long origTxnId = toLong(r.get("original_txn_id"));
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
final String origVoucher = (String) r.get("original_voucher_no");
final Integer stillLive = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false",
Integer.class, origTxnId, wrongId);
if (stillLive == null || stillLive == 0) {
throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected.");
}
// 1. Take the deposit off the wrong account, 2. put the same cash into the right one.
final JsonObject undoPayload = new JsonObject();
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(
new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build()));
final String newTxnId = depositInto(correctId, amount, r);
final Long newTxnKey = Long.valueOf(newTxnId);
// 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side.
final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user);
jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?",
r.get("correction_by_id"), r.get("correction_by_id"), newVoucher);
voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey);
addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"),
amount);
// 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this
// correction (original_voucher_no / new_voucher_no) and read from there by the registers.
// 5. The notes the cashier took in follow the deposit to its new transaction and account.
jdbcTemplate.update("""
UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ?
WHERE txn_id = ? AND savings_account_id = ?
""", newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?,
correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ?
WHERE id = ? AND status = ?
""", COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey,
log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING);
return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher));
}
/** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */
@POST
@Path("{id}/correction/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String rejectCorrection(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, CORRECTION_PENDING, "rejected");
final String reason = requireReason(body);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL,
correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ?
WHERE id = ?
""", UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id);
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
}
/**
* The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has
* already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from
* their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher
* stays as it was.
*/
@POST
@Path("{id}/close")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String close(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
if (cashierRoleHelper.getCurrentCashierId() == null) {
throw apiError(403, "Only a cashier can close a voucher correction.");
}
final Map<String, Object> r = load(id);
requireStatus(r, UNPASSED, "closed");
final String reason = requireReason(body);
final int updated = jdbcTemplate.update(
"UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED,
reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED);
if (updated == 0) {
throw apiError(409, "This request was already actioned.");
}
return gson.toJson(Map.of("resourceId", id, "status", CLOSED));
}
// ── For the passbook and transaction list of the wrong account ───────────────
/** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */
@GET
@Path("voided-transactions")
@Produces(MediaType.APPLICATION_JSON)
public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) {
this.context.authenticatedUser();
final JsonArray ids = new JsonArray();
jdbcTemplate.queryForList(
"SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED,
savingsAccountId).forEach(ids::add);
return gson.toJson(ids);
}
// ── helpers ──────────────────────────────────────────────────────────────────
/** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */
private void requireUnpasser(final AppUser user) {
final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS");
final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim()));
if (!admin && !clerk) {
throw apiError(403, "Only an administrator or a clerk can unpass a voucher.");
}
}
private Map<String, Object> validateCorrectAccount(final Map<String, Object> r, final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "The correct account number is required");
}
final Map<String, Object> correct = findSavingsAccount(accountNo);
if (correct == null) {
throw apiError(404, "No savings account found for '" + accountNo + "'.");
}
if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) {
throw apiError(400, "The correct account is the same account the deposit was posted to.");
}
if (!Boolean.TRUE.equals(correct.get("canReceive"))) {
throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit.");
}
final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
r.get("wrong_savings_account_id")).stream().findFirst().orElse(null);
if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) {
throw apiError(409, "The correct account is in a different currency.");
}
kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT);
return correct;
}
/** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */
private void checkWrongAccountBalance(final Map<String, Object> r) {
final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?",
BigDecimal.class, r.get("wrong_savings_account_id"));
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
if (balance == null || balance.compareTo(amount) < 0) {
throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹"
+ (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money.");
}
}
private Map<String, Object> findSavingsAccount(final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName",
sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode",
(sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive"
FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.account_no = ?
""", SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim());
return rows.isEmpty() ? null : rows.get(0);
}
private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map<String, Object> r) {
final Object txnDate = r.get("txn_date");
final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))
: LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (r.get("payment_type_id") != null) {
payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue());
}
payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no"));
final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command));
if (result.getResourceId() == null) {
throw apiError(500, "The corrected deposit could not be posted.");
}
return result.getResourceId().toString();
}
/** The new voucher keeps the original's till side and names the right account on the credit side. */
private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName,
final String accountNo, final BigDecimal amount) {
try {
final List<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> l : jdbcTemplate.queryForList(
"SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no",
origVoucher)) {
lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"),
amount));
}
if (lines.isEmpty()) {
lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount));
}
lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount));
voucherService.addLines(newVoucher, lines);
} catch (final RuntimeException e) {
// The deposit is already posted; a voucher without its sides is better than undoing a correct posting.
org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class)
.error("Could not record the sides of correction voucher {}", newVoucher, e);
}
}
private Map<String, Object> load(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id);
if (rows.isEmpty()) {
throw new NotFoundException("Voucher correction not found: " + id);
}
return rows.get(0);
}
private void requireStatus(final Map<String, Object> r, final String expected, final String action) {
if (!expected.equals(r.get("status"))) {
throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status"));
}
}
private void requireNotMaker(final AppUser user, final Object makerId, final String what) {
if (makerId != null && toLong(makerId).equals(user.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager.");
}
}
private String requireReason(final String body) {
final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"));
if (reason == null || reason.isBlank()) {
throw apiError(400, "Reason is required");
}
return reason.trim();
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections.");
}
}
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private String log(final String existing, final String action, final AppUser user, final String note) {
final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class);
final JsonObject e = new JsonObject();
e.addProperty("action", action);
e.addProperty("by", user.getUsername());
e.addProperty("at", Instant.now().toString());
if (note != null) {
e.addProperty("note", note);
}
entries.add(e);
return gson.toJson(entries);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -628,9 +628,15 @@ public class VoucherVerificationApiResource {
BigDecimal total = BigDecimal.ZERO;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> marks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
if (amount != null) {
final Map<String, Object> mark = marks.get((String) r.get("voucher_no"));
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
// A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it
// is left out of the total rather than counted twice.
if (amount != null && !revoked) {
total = total.add(amount);
}
@@ -638,6 +644,7 @@ public class VoucherVerificationApiResource {
m.put("voucherNo", r.get("voucher_no"));
m.put("voucherType", r.get("voucher_type"));
m.put("amount", amount);
putVoucherMark(m, mark);
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
// The day the posting belongs to, and whether that differs from the day it was written.
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
@@ -847,6 +854,8 @@ public class VoucherVerificationApiResource {
int raisedCount = 0;
int verifiedCount = 0;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> myWorkMarks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
@@ -869,7 +878,10 @@ public class VoucherVerificationApiResource {
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
? "JV" : rawSide;
if (amount != null) {
final Map<String, Object> mark = myWorkMarks.get(voucherNo);
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
// A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted.
if (amount != null && !revoked) {
if ("CR".equals(side)) {
receivedTotal = receivedTotal.add(amount);
} else if ("DR".equals(side)) {
@@ -897,6 +909,7 @@ public class VoucherVerificationApiResource {
m.put("raisedBy", r.get("created_by_username"));
m.put("branchName", r.get("branch_name"));
m.put("verificationStatus", r.get("verification_status"));
putVoucherMark(m, mark);
m.put("verifiedByName", r.get("verified_by_name"));
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
m.put("debitParty", r.get("debit_party"));
@@ -968,6 +981,14 @@ public class VoucherVerificationApiResource {
* returns an empty list rather than an error: the voucher is real, its posting simply was not
* captured at the time, and reporting it as missing would be wrong.
*/
/** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */
private void putVoucherMark(final Map<String, Object> row, final Map<String, Object> mark) {
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
}
@GET
@Path("detail")
public String detail(@QueryParam("voucherNo") final String voucherNo) {
@@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource {
out.put("createdBy", h.get("created_by_username"));
out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString());
out.put("verificationStatus", h.get("verification_status"));
putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo));
out.put("lines", lines);
out.put("totalDebit", totalDebit);
out.put("totalCredit", totalCredit);
@@ -595,6 +595,66 @@ public class VoucherService {
""", voucherNo);
}
/**
* Where each of these vouchers stands, for the registers that list vouchers.
*
* A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a
* new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING,
* UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in
* replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its
* money now sits on the replacement.
*/
public Map<String, Map<String, Object>> voucherMarks(final java.util.Collection<String> voucherNos) {
final Map<String, Map<String, Object>> marks = new java.util.HashMap<>();
final List<String> nos = voucherNos == null ? List.of()
: voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList();
if (nos.isEmpty()) {
return marks;
}
final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?"));
// Read from the correction register alone: the voucher itself carries no status, so nothing about a correction
// is ever written onto m_voucher and the voucher rows stay exactly as they were raised.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT v.voucher_no,"
+ " (SELECT vc.new_voucher_no FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
+ " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no,"
+ " (SELECT vc.original_voucher_no FROM m_voucher_correction vc"
+ " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
+ " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no,"
+ " (SELECT vc.status FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no"
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')"
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_status,"
// The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed.
+ " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END"
+ " FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no"
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')"
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_note"
+ " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")",
nos.toArray());
for (final Map<String, Object> r : rows) {
final String status;
if (r.get("revoked_by_voucher_no") != null) {
status = "REVOKED";
} else if (r.get("correction_status") != null) {
status = (String) r.get("correction_status");
} else if (r.get("replaces_voucher_no") != null) {
status = "CORRECTED";
} else {
status = "ACTIVE";
}
final Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("voucherStatus", status);
m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no"));
m.put("replacesVoucherNo", r.get("replaces_voucher_no"));
m.put("correctionNote", r.get("correction_note"));
marks.put((String) r.get("voucher_no"), m);
}
return marks;
}
/**
* Looks up the voucher for the given request and returns its voucher_no,
* or null if none exists (e.g. records created before vouchers existed).
@@ -173,4 +173,11 @@
<include file="parts/0597_enable_recurring_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0604_cic_credit_bureau_module.xml" relativeToChangelogFile="true"/>
<include file="parts/0605_cic_enquiry_alerts.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Cash exchange records who the customer was: an existing client is already linked through client_id,
a walk-in customer through the name and mobile captured at the counter. pan_number holds the PAN the
cashier verified, which is mandatory above Rs 50,000 (Income Tax Rule 114B, followed under RBI
cash-handling guidelines). All three are nullable: exchanges recorded before this change have none.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0599_cash_exchange_add_customer_name" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="customer_name"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="customer_name" type="VARCHAR(150)"/>
</addColumn>
</changeSet>
<changeSet id="0599_cash_exchange_add_customer_mobile" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="customer_mobile"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="customer_mobile" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
<changeSet id="0599_cash_exchange_add_pan_number" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="pan_number"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="pan_number" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
from. All four columns are nullable: savings, current and deposit requests do not use them.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_clerk_withdrawal_request"/>
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
</preConditions>
<addColumn tableName="m_clerk_withdrawal_request">
<column name="gl_account_id" type="BIGINT"/>
<column name="contra_gl_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="currency_code" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Voucher correction: a cash deposit posted to the wrong savings account is unpassed, approved, re-entered against
the right account by the cashier and approved again. m_voucher_correction carries that request through its steps;
m_voucher gains a status so the original voucher can be REVOKED once its replacement exists.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0601_voucher_add_status" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_voucher" columnName="status"/></not>
</preConditions>
<addColumn tableName="m_voucher">
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_at" type="TIMESTAMP"/>
<column name="revoked_by_voucher_no" type="VARCHAR(30)"/>
</addColumn>
</changeSet>
<changeSet id="0601_create_voucher_correction" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_correction"/></not>
</preConditions>
<createTable tableName="m_voucher_correction">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="original_voucher_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="original_txn_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_savings_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_account_no" type="VARCHAR(50)"/>
<column name="wrong_client_name" type="VARCHAR(255)"/>
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="payment_type_id" type="BIGINT"/>
<column name="txn_date" type="DATE"/>
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="reason" type="VARCHAR(1000)"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="unpass_by_id" type="BIGINT"/>
<column name="unpass_by_name" type="VARCHAR(255)"/>
<column name="unpass_at" type="TIMESTAMP"/>
<column name="unpass_approved_by_id" type="BIGINT"/>
<column name="unpass_approved_by_name" type="VARCHAR(255)"/>
<column name="unpass_approved_at" type="TIMESTAMP"/>
<column name="correct_savings_account_id" type="BIGINT"/>
<column name="correct_account_no" type="VARCHAR(50)"/>
<column name="correct_client_id" type="BIGINT"/>
<column name="correct_client_name" type="VARCHAR(255)"/>
<column name="correction_by_id" type="BIGINT"/>
<column name="correction_by_name" type="VARCHAR(255)"/>
<column name="correction_at" type="TIMESTAMP"/>
<column name="correction_approved_by_id" type="BIGINT"/>
<column name="correction_approved_by_name" type="VARCHAR(255)"/>
<column name="correction_approved_at" type="TIMESTAMP"/>
<column name="new_voucher_no" type="VARCHAR(30)"/>
<column name="new_txn_id" type="BIGINT"/>
<column name="activity_log" type="TEXT"/>
</createTable>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_voucher">
<column name="original_voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
The voucher itself carries no correction status: whether a voucher was unpassed, revoked or closed is read from
m_voucher_correction. 0601 had added status, revoked_at and revoked_by_voucher_no to m_voucher; nothing writes them
any more, so they are dropped and the voucher table is back to exactly what the registers raised.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0602_voucher_drop_status_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_voucher" columnName="status"/>
</preConditions>
<dropColumn tableName="m_voucher" columnName="status"/>
<dropColumn tableName="m_voucher" columnName="revoked_at"/>
<dropColumn tableName="m_voucher" columnName="revoked_by_voucher_no"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Cash Transfer is maker-checker: the person who allocates cash from the vault to a cashier raises a request, and the
allocation is posted only when a different person approves it. m_cash_transfer stays the table of posted transfers;
this table holds the requests, with the whole request body so the approval posts exactly what was asked for.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0603_create_cash_transfer_request" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cash_transfer_request"/></not>
</preConditions>
<createTable tableName="m_cash_transfer_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="from_teller_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="to_cashier_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="from_party" type="VARCHAR(255)"/>
<column name="to_party" type="VARCHAR(255)"/>
<column name="note" type="VARCHAR(1000)"/>
<column name="total_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="request_body" type="TEXT"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="maker_id" type="BIGINT"/>
<column name="maker_name" type="VARCHAR(255)"/>
<column name="maker_at" type="TIMESTAMP"/>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(255)"/>
<column name="checker_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="cash_transfer_id" type="BIGINT"/>
</createTable>
<createIndex tableName="m_cash_transfer_request" indexName="idx_cash_transfer_request_status">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,294 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
CIC / Credit Bureau Enquiry module (CIBIL TransUnion, Experian, Equifax, CRIF High Mark).
m_cic_bureau - the bank's bureau master: whitelisted or not, environment, connection and credentials
(the secret is stored encrypted with the tenant master password).
m_cic_bureau_endpoint - per bureau and enquiry type: method, path, headers and a base JSON body.
m_cic_field_mapping - the API mapping, maintained from the UI: REQUEST rows put a CBS field into the bureau's
request at a JSON path (with a transform); RESPONSE rows read a JSON path of the bureau's
response into a CBS result field (score, accounts, overdue ...).
m_cic_consent - the borrower's consent to a bureau pull, required before any enquiry.
m_cic_enquiry - every enquiry: masked request, raw response, normalised result, score and status.
Every bureau is seeded NOT whitelisted, in SIMULATOR mode, with a generic request/response mapping the built-in
simulator answers to. Going live is: enter the bureau's URL and credentials, adapt the mapping to the bureau's own
specification, switch the environment, and whitelist it.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0604-1-cic-tables" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cic_bureau"/></not>
</preConditions>
<createTable tableName="m_cic_bureau">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="code" type="VARCHAR(30)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_bureau_code"/></column>
<column name="name" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="description" type="VARCHAR(500)"/>
<column name="whitelisted" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="is_default" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="environment" type="VARCHAR(20)" defaultValue="SIMULATOR"><constraints nullable="false"/></column>
<column name="base_url" type="VARCHAR(500)"/>
<column name="member_code" type="VARCHAR(100)"/>
<column name="auth_type" type="VARCHAR(40)" defaultValue="NONE"><constraints nullable="false"/></column>
<column name="username" type="VARCHAR(200)"/>
<column name="api_key_header" type="VARCHAR(100)" defaultValue="X-API-KEY"/>
<column name="secret_enc" type="TEXT"/>
<column name="token_url" type="VARCHAR(500)"/>
<column name="client_id" type="VARCHAR(200)"/>
<column name="timeout_ms" type="INT" defaultValueNumeric="30000"><constraints nullable="false"/></column>
<column name="report_validity_days" type="INT" defaultValueNumeric="30"><constraints nullable="false"/></column>
<column name="enquiry_charge" type="DECIMAL(19,6)" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="last_test_status" type="VARCHAR(20)"/>
<column name="last_test_message" type="VARCHAR(1000)"/>
<column name="last_tested_on" type="TIMESTAMP"/>
<column name="updated_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP"/>
</createTable>
<createTable tableName="m_cic_bureau_endpoint">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_endpoint_bureau" references="m_cic_bureau(id)"/></column>
<column name="endpoint_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="http_method" type="VARCHAR(10)" defaultValue="POST"><constraints nullable="false"/></column>
<column name="path" type="VARCHAR(500)"/>
<column name="content_type" type="VARCHAR(100)" defaultValue="application/json"/>
<column name="headers_json" type="TEXT"/>
<column name="base_body" type="TEXT"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
<column name="updated_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP"/>
</createTable>
<addUniqueConstraint tableName="m_cic_bureau_endpoint" columnNames="bureau_id, endpoint_type" constraintName="uq_cic_endpoint_type"/>
<createTable tableName="m_cic_field_mapping">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="endpoint_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_mapping_endpoint" references="m_cic_bureau_endpoint(id)" deleteCascade="true"/></column>
<column name="direction" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="source_field" type="VARCHAR(200)"/>
<column name="target_field" type="VARCHAR(200)"><constraints nullable="false"/></column>
<column name="transform" type="VARCHAR(40)" defaultValue="NONE"/>
<column name="default_value" type="VARCHAR(200)"/>
<column name="is_mandatory" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="sort_order" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_cic_field_mapping" indexName="idx_cic_mapping_endpoint"><column name="endpoint_id"/></createIndex>
<createTable tableName="m_cic_consent">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="consent_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_consent_ref"/></column>
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="client_name" type="VARCHAR(200)"/>
<column name="application_no" type="VARCHAR(50)"/>
<column name="bureau_id" type="BIGINT"/>
<column name="purpose" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="consent_mode" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="consent_text" type="TEXT"/>
<column name="document_ref" type="VARCHAR(200)"/>
<column name="otp_reference" type="VARCHAR(100)"/>
<column name="valid_from" type="DATE"><constraints nullable="false"/></column>
<column name="valid_until" type="DATE"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"><constraints nullable="false"/></column>
<column name="revoked_reason" type="VARCHAR(500)"/>
<column name="revoked_by" type="BIGINT"/>
<column name="revoked_on" type="TIMESTAMP"/>
<column name="captured_by" type="BIGINT"/>
<column name="captured_by_name" type="VARCHAR(200)"/>
<column name="captured_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="ip_address" type="VARCHAR(64)"/>
</createTable>
<createIndex tableName="m_cic_consent" indexName="idx_cic_consent_client"><column name="client_id"/></createIndex>
<createTable tableName="m_cic_enquiry">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="enquiry_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_enquiry_ref"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="client_name" type="VARCHAR(200)"/>
<column name="application_no" type="VARCHAR(50)"/>
<column name="consent_id" type="BIGINT"/>
<column name="enquiry_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="purpose" type="VARCHAR(40)"/>
<column name="loan_amount" type="DECIMAL(19,6)"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="score" type="INT"/>
<column name="http_status" type="INT"/>
<column name="error_message" type="VARCHAR(2000)"/>
<column name="request_masked" type="TEXT"/>
<column name="response_raw" type="TEXT"/>
<column name="result_json" type="TEXT"/>
<column name="reused_from_id" type="BIGINT"/>
<column name="charge_amount" type="DECIMAL(19,6)"/>
<column name="environment" type="VARCHAR(20)"/>
<column name="office_id" type="BIGINT"/>
<column name="requested_by" type="BIGINT"/>
<column name="requested_by_name" type="VARCHAR(200)"/>
<column name="requested_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="duration_ms" type="INT"/>
</createTable>
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_client"><column name="client_id"/></createIndex>
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_bureau_date"><column name="bureau_id"/><column name="requested_on"/></createIndex>
</changeSet>
<!-- The four RBI-licensed CICs. Seeded off (not whitelisted) and in SIMULATOR mode. -->
<changeSet id="0604-2-cic-bureaus" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau</sqlCheck>
</preConditions>
<insert tableName="m_cic_bureau">
<column name="code" value="CIBIL"/><column name="name" value="TransUnion CIBIL"/>
<column name="description" value="TransUnion CIBIL Limited - consumer and commercial credit information"/>
<column name="is_default" valueBoolean="true"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="EXPERIAN"/><column name="name" value="Experian Credit Information Company of India"/>
<column name="description" value="Experian India - consumer and commercial credit information"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="EQUIFAX"/><column name="name" value="Equifax Credit Information Services"/>
<column name="description" value="Equifax India - consumer, commercial and microfinance credit information"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="CRIF"/><column name="name" value="CRIF High Mark Credit Information Services"/>
<column name="description" value="CRIF High Mark - consumer, commercial and microfinance credit information"/>
</insert>
</changeSet>
<!-- A CONSUMER_ENQUIRY endpoint per bureau, with a generic body skeleton the simulator understands. -->
<changeSet id="0604-3-cic-endpoints" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau_endpoint</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
SELECT b.id, 'CONSUMER_ENQUIRY', 'POST', '/consumer/enquiry', 'application/json',
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{enquiry.reference}}"}',
'{"requestHeader":{"product":"CONSUMER_REPORT","version":"1.0"},"consumer":{},"enquiry":{}}',
TRUE
FROM m_cic_bureau b
</sql>
</changeSet>
<!-- The generic API mapping. Adapt it to each bureau's own specification from the API Mapping screen. -->
<changeSet id="0604-4-cic-mappings" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_field_mapping</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
FROM m_cic_bureau_endpoint e
CROSS JOIN (
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
UNION ALL SELECT 'REQUEST', 'enquiry.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
UNION ALL SELECT 'REQUEST', 'enquiry.date', 'requestHeader.enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 30
UNION ALL SELECT 'REQUEST', 'client.firstName', 'consumer.name.firstName', 'UPPER', NULL, TRUE, 40
UNION ALL SELECT 'REQUEST', 'client.middleName', 'consumer.name.middleName', 'UPPER', NULL, FALSE, 50
UNION ALL SELECT 'REQUEST', 'client.lastName', 'consumer.name.lastName', 'UPPER', NULL, FALSE, 60
UNION ALL SELECT 'REQUEST', 'client.dob', 'consumer.dateOfBirth', 'DATE_DDMMYYYY', NULL, TRUE, 70
UNION ALL SELECT 'REQUEST', 'client.gender', 'consumer.gender', 'GENDER_MF', NULL, FALSE, 80
UNION ALL SELECT 'REQUEST', NULL, 'consumer.ids[0].type', 'NONE', 'PAN', FALSE, 90
UNION ALL SELECT 'REQUEST', 'client.pan', 'consumer.ids[0].number', 'UPPER', NULL, TRUE, 100
UNION ALL SELECT 'REQUEST', NULL, 'consumer.phones[0].type', 'NONE', 'MOBILE', FALSE, 110
UNION ALL SELECT 'REQUEST', 'client.mobile', 'consumer.phones[0].number', 'DIGITS_ONLY', NULL, FALSE, 120
UNION ALL SELECT 'REQUEST', 'client.email', 'consumer.email', 'LOWER', NULL, FALSE, 130
UNION ALL SELECT 'REQUEST', 'client.addressLine1', 'consumer.address.line1', 'UPPER', NULL, FALSE, 140
UNION ALL SELECT 'REQUEST', 'client.addressLine2', 'consumer.address.line2', 'UPPER', NULL, FALSE, 150
UNION ALL SELECT 'REQUEST', 'client.city', 'consumer.address.city', 'UPPER', NULL, FALSE, 160
UNION ALL SELECT 'REQUEST', 'client.state', 'consumer.address.stateCode', 'STATE_CODE', NULL, FALSE, 170
UNION ALL SELECT 'REQUEST', 'client.pincode', 'consumer.address.pincode', 'DIGITS_ONLY', NULL, FALSE, 180
UNION ALL SELECT 'REQUEST', 'enquiry.amount', 'enquiry.amount', 'AMOUNT_INTEGER', NULL, TRUE, 190
UNION ALL SELECT 'REQUEST', 'enquiry.purposeCode', 'enquiry.purpose', 'NONE', NULL, TRUE, 200
UNION ALL SELECT 'RESPONSE', 'report.hit', 'hit', 'NONE', NULL, FALSE, 10
UNION ALL SELECT 'RESPONSE', 'report.reportId', 'reportId', 'NONE', NULL, FALSE, 20
UNION ALL SELECT 'RESPONSE', 'report.score.value', 'score', 'NUMBER', NULL, FALSE, 30
UNION ALL SELECT 'RESPONSE', 'report.score.version', 'scoreVersion', 'NONE', NULL, FALSE, 40
UNION ALL SELECT 'RESPONSE', 'report.summary.totalAccounts', 'totalAccounts', 'NUMBER', NULL, FALSE, 50
UNION ALL SELECT 'RESPONSE', 'report.summary.activeAccounts', 'activeAccounts', 'NUMBER', NULL, FALSE, 60
UNION ALL SELECT 'RESPONSE', 'report.summary.overdueAccounts', 'overdueAccounts', 'NUMBER', NULL, FALSE, 70
UNION ALL SELECT 'RESPONSE', 'report.summary.writtenOffAccounts', 'writtenOffAccounts', 'NUMBER', NULL, FALSE, 80
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOutstanding', 'totalOutstanding', 'NUMBER', NULL, FALSE, 90
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOverdue', 'totalOverdue', 'NUMBER', NULL, FALSE, 100
UNION ALL SELECT 'RESPONSE', 'report.summary.enquiriesLast6Months', 'enquiriesLast6Months', 'NUMBER', NULL, FALSE, 110
UNION ALL SELECT 'RESPONSE', 'report.summary.maxDpd', 'maxDpd', 'NUMBER', NULL, FALSE, 120
UNION ALL SELECT 'RESPONSE', 'report.accounts', 'accounts', 'NONE', NULL, FALSE, 130
UNION ALL SELECT 'RESPONSE', 'report.enquiries', 'enquiries', 'NONE', NULL, FALSE, 140
) m
</sql>
</changeSet>
<!-- Permissions. Configuration (UPDATE_CICBUREAU) is left to super users; enquiry and consent go to every role that
already works Loan Origination, which also gets the new top-nav menu. -->
<changeSet id="0604-5-cic-permissions" author="cbsnexora">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'heading_menu', 'MENU_CREDIT_BUREAU', 'MENU', 'CREDIT_BUREAU', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'MENU_CREDIT_BUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICBUREAU', 'CICBUREAU', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICBUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'UPDATE_CICBUREAU', 'CICBUREAU', 'UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICBUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICENQUIRY', 'CICENQUIRY', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICENQUIRY');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'CREATE_CICENQUIRY', 'CICENQUIRY', 'CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICENQUIRY');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICCONSENT', 'CICCONSENT', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICCONSENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'CREATE_CICCONSENT', 'CICCONSENT', 'CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICCONSENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'REVOKE_CICCONSENT', 'CICCONSENT', 'REVOKE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVOKE_CICCONSENT');
</sql>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT rp.role_id, p2.id
FROM m_role_permission rp
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_LOAN_ORIGINATION'
JOIN m_permission p2 ON p2.code IN ('MENU_CREDIT_BUREAU', 'READ_CICBUREAU', 'READ_CICENQUIRY', 'CREATE_CICENQUIRY',
'READ_CICCONSENT', 'CREATE_CICCONSENT')
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
</sql>
</changeSet>
<changeSet id="0604-6-cic-menu-access" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_tenant_menu_access"/>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_CREDIT_BUREAU'</sqlCheck>
</and>
</preConditions>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CREDIT_BUREAU"/></insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
CIC module, second shape: the bank no longer pulls credit reports. Instead the CIC tells the bank when one of
ITS customers was enquired by ANOTHER bank / NBFC - i.e. the customer applied for a loan elsewhere - with the
lender, loan type and amount. These "enquiry alerts" are fetched through the same bureau configuration and
UI-driven API mapping (endpoint type ENQUIRY_ALERT) and shown on the Credit Bureau dashboard.
Removed: borrower consent (m_cic_consent), report enquiries (m_cic_enquiry), their permissions, and the
CONSUMER_ENQUIRY endpoints / mappings.
Added: m_cic_alert (one row per enquiry made on our customer at another lender),
m_cic_alert_fetch_run (every fetch / file import with its counts and raw response),
alert fetch settings on m_cic_bureau, the ENQUIRY_ALERT endpoint and its generic mapping.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0605-1-drop-cic-enquiry-consent" author="cbsnexora">
<sql>
DROP TABLE IF EXISTS m_cic_enquiry;
DROP TABLE IF EXISTS m_cic_consent;
DELETE FROM m_cic_field_mapping
WHERE endpoint_id IN (SELECT id FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY'));
DELETE FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY');
DELETE FROM m_role_permission
WHERE permission_id IN (SELECT id FROM m_permission
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT',
'REVOKE_CICCONSENT'));
DELETE FROM m_permission
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT', 'REVOKE_CICCONSENT');
</sql>
</changeSet>
<changeSet id="0605-2-cic-bureau-alert-settings" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_cic_bureau" columnName="report_validity_days"/>
</preConditions>
<dropColumn tableName="m_cic_bureau" columnName="report_validity_days"/>
<dropColumn tableName="m_cic_bureau" columnName="enquiry_charge"/>
<addColumn tableName="m_cic_bureau">
<column name="alert_auto_fetch" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
<column name="alert_fetch_interval_min" type="INT" defaultValueNumeric="60"><constraints nullable="false"/></column>
<column name="alert_lookback_days" type="INT" defaultValueNumeric="7"><constraints nullable="false"/></column>
<column name="last_fetch_on" type="TIMESTAMP"/>
<column name="last_fetch_status" type="VARCHAR(20)"/>
<column name="last_fetch_message" type="VARCHAR(1000)"/>
</addColumn>
</changeSet>
<changeSet id="0605-3-cic-alert-tables" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cic_alert"/></not>
</preConditions>
<createTable tableName="m_cic_alert_fetch_run">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="run_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_fetch_run_ref"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="trigger_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="environment" type="VARCHAR(20)"/>
<column name="from_date" type="DATE"/>
<column name="to_date" type="DATE"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="http_status" type="INT"/>
<column name="received" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="new_alerts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="matched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="unmatched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="duplicates" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="own_bank" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="error_message" type="VARCHAR(2000)"/>
<column name="request_masked" type="TEXT"/>
<column name="response_raw" type="TEXT"/>
<column name="duration_ms" type="INT"/>
<column name="triggered_by" type="BIGINT"/>
<column name="triggered_by_name" type="VARCHAR(200)"/>
<column name="started_on" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_cic_alert_fetch_run" indexName="idx_cic_fetch_run_bureau"><column name="bureau_id"/><column name="started_on"/></createIndex>
<createTable tableName="m_cic_alert">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="alert_key" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="client_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(200)"/>
<column name="client_account_no" type="VARCHAR(50)"/>
<column name="match_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="customer_name" type="VARCHAR(200)"/>
<column name="pan_masked" type="VARCHAR(20)"/>
<column name="aadhaar_masked" type="VARCHAR(20)"/>
<column name="customer_ref" type="VARCHAR(100)"/>
<column name="date_of_birth" type="DATE"/>
<column name="institution_code" type="VARCHAR(100)"/>
<column name="institution_name" type="VARCHAR(200)"/>
<column name="institution_type" type="VARCHAR(60)"/>
<column name="enquiry_date" type="DATE"/>
<column name="loan_type" type="VARCHAR(100)"/>
<column name="amount" type="DECIMAL(19,6)"/>
<column name="purpose" type="VARCHAR(200)"/>
<column name="location" type="VARCHAR(200)"/>
<column name="status" type="VARCHAR(20)" defaultValue="NEW"><constraints nullable="false"/></column>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="actioned_by" type="BIGINT"/>
<column name="actioned_by_name" type="VARCHAR(200)"/>
<column name="actioned_on" type="TIMESTAMP"/>
<column name="received_via" type="VARCHAR(20)"/>
<column name="fetch_run_id" type="BIGINT"/>
<column name="received_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="raw_json" type="TEXT"/>
</createTable>
<addUniqueConstraint tableName="m_cic_alert" columnNames="bureau_id, alert_key" constraintName="uq_cic_alert_key"/>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_client"><column name="client_id"/></createIndex>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_status"><column name="status"/></createIndex>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_enquiry_date"><column name="enquiry_date"/></createIndex>
</changeSet>
<!-- An ENQUIRY_ALERT endpoint per bureau, with a generic body the built-in simulator answers to. -->
<changeSet id="0605-4-cic-alert-endpoints" author="cbsnexora">
<sql>
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
SELECT b.id, 'ENQUIRY_ALERT', 'POST', '/monitoring/enquiry-alerts', 'application/json',
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{fetch.reference}}"}',
'{"requestHeader":{"product":"ENQUIRY_ALERT","version":"1.0"},"criteria":{}}',
TRUE
FROM m_cic_bureau b
WHERE NOT EXISTS (SELECT 1 FROM m_cic_bureau_endpoint e WHERE e.bureau_id = b.id AND e.endpoint_type = 'ENQUIRY_ALERT');
</sql>
<sql>
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
FROM m_cic_bureau_endpoint e
CROSS JOIN (
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
UNION ALL SELECT 'REQUEST', 'fetch.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
UNION ALL SELECT 'REQUEST', 'fetch.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', NULL, TRUE, 30
UNION ALL SELECT 'REQUEST', 'fetch.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', NULL, TRUE, 40
UNION ALL SELECT 'REQUEST', NULL, 'criteria.scope', 'NONE', 'OTHER_MEMBERS', FALSE, 50
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorCode', 'errorCode', 'NONE', NULL, FALSE, 10
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorMessage', 'errorMessage', 'NONE', NULL, FALSE, 20
UNION ALL SELECT 'RESPONSE', 'data.alerts', 'alerts', 'NONE', NULL, FALSE, 30
UNION ALL SELECT 'RESPONSE', 'alertId', 'alertRef', 'NONE', NULL, FALSE, 40
UNION ALL SELECT 'RESPONSE', 'consumer.pan', 'pan', 'UPPER', NULL, FALSE, 50
UNION ALL SELECT 'RESPONSE', 'consumer.aadhaar', 'aadhaar', 'NONE', NULL, FALSE, 60
UNION ALL SELECT 'RESPONSE', 'consumer.name', 'customerName', 'UPPER', NULL, FALSE, 70
UNION ALL SELECT 'RESPONSE', 'consumer.dateOfBirth', 'dateOfBirth', 'DATE_DDMMYYYY', NULL, FALSE, 80
UNION ALL SELECT 'RESPONSE', 'consumer.memberReference', 'customerRef', 'NONE', NULL, FALSE, 90
UNION ALL SELECT 'RESPONSE', 'enquiry.memberCode', 'institutionCode', 'NONE', NULL, FALSE, 100
UNION ALL SELECT 'RESPONSE', 'enquiry.memberName', 'institutionName', 'NONE', NULL, FALSE, 110
UNION ALL SELECT 'RESPONSE', 'enquiry.memberType', 'institutionType', 'NONE', NULL, FALSE, 120
UNION ALL SELECT 'RESPONSE', 'enquiry.date', 'enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 130
UNION ALL SELECT 'RESPONSE', 'enquiry.productType', 'loanType', 'NONE', NULL, FALSE, 140
UNION ALL SELECT 'RESPONSE', 'enquiry.amount', 'amount', 'NUMBER', NULL, FALSE, 150
UNION ALL SELECT 'RESPONSE', 'enquiry.purpose', 'purpose', 'NONE', NULL, FALSE, 160
UNION ALL SELECT 'RESPONSE', 'enquiry.location', 'location', 'NONE', NULL, FALSE, 170
) m
WHERE e.endpoint_type = 'ENQUIRY_ALERT'
AND NOT EXISTS (SELECT 1 FROM m_cic_field_mapping x WHERE x.endpoint_id = e.id);
</sql>
</changeSet>
<!-- Alert permissions; every role that has the Credit Bureau menu may read, work and fetch alerts. -->
<changeSet id="0605-5-cic-alert-permissions" author="cbsnexora">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICALERT', 'CICALERT', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICALERT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'UPDATE_CICALERT', 'CICALERT', 'UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICALERT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'FETCH_CICALERT', 'CICALERT', 'FETCH', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'FETCH_CICALERT');
</sql>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT rp.role_id, p2.id
FROM m_role_permission rp
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_CREDIT_BUREAU'
JOIN m_permission p2 ON p2.code IN ('READ_CICALERT', 'UPDATE_CICALERT', 'FETCH_CICALERT')
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -39,6 +39,13 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command);
/**
* Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD
* is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go
* through {@link #depositToFDAccount}.
*/
void recalculateFixedDepositMaturity(Long savingsId);
CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType);