Compare commits

...
14 Commits
Author SHA1 Message Date
anshul 08519da8b1 feat: implement maker-checker workflow for user creation and update requests
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 Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (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 - 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-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-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
2026-08-27 17:51:22 +05:30
Anshul Rathore cad7f54c47 feat: implement maker-checker for product creation, add app user preferences, and introduce base interest rate management services.
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 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 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 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 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 - MySQL / test (test-core-1) (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
2026-08-26 10:38:09 +05:30
nitesh 8724c54d9b Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
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 - 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 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 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
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
2026-08-19 10:50:25 +05:30
nitesh 5c0a482f49 feat(kyc): resolve DIP/MNRL demo verdicts locally, make the check toggleable
Every mobile-number lookup was calling out to a local mock HTTP service
on port 8090 that had to be started separately, so the check failed
with a connection error whenever that process wasn't running.

Ports the mock's full verdict table (suspected, disconnected,
suspected+disconnected, clean) into MobileLookupServiceImpl itself, so
the demo/test numbers resolve in-process with no HTTP call. Adds
fineract.mobile-lookup.enabled (default off) to skip the real DIP/MNRL
call entirely for local work; every number then resolves CLEAN and is
stamped LOOKUP_DISABLED_BY_CONFIG in the log so it's never mistaken
for a genuine pass. Must be turned back on before UAT/production.
2026-08-19 10:35:48 +05:30
nitesh 0e07e60328 feat(current-accounts): add document upload/list/download/delete endpoints
/v1/currentaccounts/{id}/documents had no handler at all -- the
account's own /v1/currentaccounts resource matches the request path
first and has no documents method, so it never fell through to the
generic /v1/{entityType}/{entityId}/documents resource that every
other entity type relies on. Current accounts got a 404 on every
Documents-tab request.

Delegates straight to the existing DocumentApiResource so current
accounts get identical behaviour to clients, loans, etc.
2026-08-19 10:35:35 +05:30
nitesh e227e8ae0f fix(savings): post GL entries for facility-charge collections at edit approval
Charges collected when a maker-checker Nominee/Digital-Feature edit is
approved deducted the account balance and appeared in the passbook,
but never posted a journal entry -- collectCharges() saved the charge
transaction directly and skipped the accounting bridge entirely, so
the GL never balanced against the subledger.

Also fixes an unrelated NPE this exposed: collectCharges() used
saveAll() instead of saveAllAndFlush(), so the new charge transaction
had no id yet when the journal-entry code tried to read it.
2026-08-19 10:35:18 +05:30
nitesh 231919b62b fix(savings): stop crediting accounts automatically on activation
Activation for Savings, RD, FD and Current accounts no longer books an
opening deposit or enforces a minimum opening balance -- accounts go
active at zero balance and a clerk deposits funds afterward as a
separate transaction, matching how these branches actually operate.

Fixed Deposit and Recurring Deposit have their own override of
activateWithBalance() that a prior fix to the shared SavingsAccount
base class missed, so they kept auto-depositing after Savings was
already fixed. Removing that auto-deposit exposed two follow-on bugs
in the FD/RD activation flow, now also fixed:
- the opening deposit's journal entries were posted twice (once by the
  deposit helper, once by the activation command's own posting call)
- FD/RD maturity amount was computed from the account's real (now
  empty) transaction history at activation and never recalculated once
  a deposit actually landed, so it showed no projected interest until
  the account was reloaded from a lucky code path
2026-08-19 10:34:54 +05:30
keshavandClaude Opus 5 9eac40bb5c feat: add database export API, welcome kit return tracking, and sanctions/mobile lookup updates
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (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-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 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 - 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 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 (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 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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:55:51 +05:30
niteshandClaude Sonnet 5 14b2585cf5 feat(loan-origination): add full backend persistence for the sanctioning pipeline
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 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 - 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 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 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
Adds a lightweight loan-origination-pipeline module (application, stage history,
approvals, FI checks and documents) with server-side validation per stage
transition, so every field, upload and decision on the Loan Origination screen
survives a page refresh instead of living only in the browser.

Also fixes two content-store bugs found while wiring document uploads through
the generic Fineract document store: the CSV mime/extension whitelist rejected
bank-statement uploads outright, and the post-upload mime consistency check
rejected any CSV whose byte-content sniffs as text/plain (which is all of them,
since CSV has no distinguishing magic bytes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 17:12:43 +05:30
Anshul Rathore a2fa8688ae Update client KYC and AML changes
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 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 - 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 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
2026-08-14 12:54:43 +05:30
keshav a8ca7dd948 feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
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 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 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 - PostgreSQL / test (test-core-5) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 Documentation build / build (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
- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports.
- Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons.
- Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars.
- Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval.
- Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names.
- Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks.
- Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
2026-08-14 10:46:59 +05:30
niteshandClaude Sonnet 5 c8833367b9 feat(shares): route dividend cash claims through cashier token disbursement
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 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 Documentation build / build (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 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 - 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 E2E Tests / E2E Tests (Shard 6 of 10) (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
Cash claims now mirror the existing clerk-withdrawal token flow: clerk
submits with a token, manager approval posts the GL entry, and a cashier
redeems the token at Transaction Entry to record physical hand-over --
no direct manager-to-member cash handover, and no double-disbursement.

Also fixes GL accounting for the loan/savings legs: instead of reversing
the native command's own debit with a second manual entry pair (leaving
the fund-source account confusingly appearing twice), that debit leg is
now rewritten in place to Dividend Payable. Floors cash claims to whole
rupees since notes/coins can't represent paise, with backward-compatible
tolerance for claims submitted before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 10:17:35 +05:30
nitesh 94b0afff38 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (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-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 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 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 - 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 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
# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
2026-08-11 12:53:20 +05:30
niteshandClaude Sonnet 5 9dd4f8bedb feat(shares): real-profit driven dividends with GL-only liability model and member claim workflow
Reworks the share dividend lifecycle end to end:
- Dividend amount now auto-computed from real GL profit x officer-entered percentage
  instead of manual entry; adds a Dividend Paid report over the existing Dividend
  Payable ledger.
- Declaration posts a real GL entry (Dr Reserve for Dividend / Cr Dividend Payable);
  approval no longer credits any savings account up front -- every member's
  entitlement stays a pure "Dividend Payable" liability until claimed, resolved
  synchronously at approval time (no background job or per-member failure mode).
- New maker-checker Dividend Claim Request workflow: a clerk records how a member
  wants their unclaimed dividend split (overdue loan / savings credit / cash), a
  manager approves, which executes native repayment/deposit commands and replenishes
  whichever GL account they actually debited (not a hardcoded assumption) from the
  Dividend Payable liability.
- Unclaimed dividends carry a 3-year window (m_share_dividend_lock) after which a
  daily job forfeits them straight to profit; a Dividend Dashboard consolidates
  summary stats, every declaration's status, full lock history, and proactive
  loan-application for members with overdue EMIs.
- Fixes a native negative-share-days bug in ShareProductDividendAssembler and adds
  overlap-period validation so a product can't have two declarations covering the
  same days.
- Removes the now-unreachable failed/pending-payout tracking that the old
  per-member job-based model needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:42:45 +05:30
145 changed files with 10790 additions and 363 deletions
@@ -436,7 +436,9 @@ public final class AccountingConstants {
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
private final Integer value;
private final String code;
@@ -137,7 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || user.isCheckerSuperUser()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,6 +151,25 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -2211,6 +2211,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createCbsBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSBASEINTERESTRATE";
this.entityId = null;
this.href = "/fixeddepositproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
boolean isMakerCheckerEnabled();
/**
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
@@ -38,6 +38,7 @@ public enum JobName {
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
@@ -191,6 +191,7 @@ public class ClientApiConstants {
public static final String constitutionIdParamName = "constitutionId";
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
public static final String authorizedSignatoryParamName = "authorizedSignatory";
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
public static final String additionalInfoParamName = "additionalInfo";
public static final String dateOfRegistrationParamName = "dateOfRegistration";
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
private Boolean alternateDipVerified;
private Long alternateDipLookupLogId;
private String alternateDipTimestamp;
/**
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
* reassigned would otherwise keep showing "Verified" forever.
*/
private String dipLiveStatus;
private String alternateDipLiveStatus;
/**
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
* would keep reading "Clear" after the client was added to a later list snapshot.
*/
private String sanctionsAlertStatus;
private Boolean nameVerified;
private Long screeningLogId;
private Boolean isStaff;
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
private final CodeValueData mainBusinessLine;
private final String remarks;
private final String authorizedSignatory;
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
private final String authorizedSignatoryClientIds;
private final String additionalInfo;
private final LocalDate dateOfRegistration;
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
this.constitution = null;
this.mainBusinessLine = null;
this.authorizedSignatory = null;
this.authorizedSignatoryClientIds = null;
this.additionalInfo = null;
this.dateOfRegistration = null;
}
/**
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
*/
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) {
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration, null);
}
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
this.constitution = constitution;
this.incorpNumber = incorpNo;
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
this.mainBusinessLine = mainBusinessLine;
this.remarks = remarks;
this.authorizedSignatory = authorizedSignatory;
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
this.additionalInfo = additionalInfo;
this.dateOfRegistration = dateOfRegistration;
}
@@ -294,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "alternate_dip_timestamp")
private String alternateDipTimestamp;
/**
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
* service was down", which previously stored both as verified.
*/
@Column(name = "dip_status", length = 40)
private String dipStatus;
@Column(name = "alternate_dip_status", length = 40)
private String alternateDipStatus;
@Column(name = "name_verified")
private Boolean nameVerified;
@@ -25,11 +25,15 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
@@ -61,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@JoinColumn(name = "client_id", nullable = false)
private Client client;
/**
* Scratch record of what the in-flight {@link #update} replaced, read back via
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
* transaction that called update(), which clears it first.
*/
@Transient
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
@Column(name = "kyc_status", nullable = false)
private Integer kycStatus;
@@ -440,11 +452,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>();
this.fieldsOverwrittenByLastUpdate.clear();
final String panNumberParam = "panNumber";
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
actualChanges.put(panNumberParam, newValue);
recordOverwrite(panNumberParam, this.panNumber);
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
}
@@ -452,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
actualChanges.put(aadhaarParam, newValue);
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
}
@@ -459,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
final String newValue = command.stringValueOfParameterNamed(gstinParam);
actualChanges.put(gstinParam, newValue);
recordOverwrite(gstinParam, this.gstinNumber);
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
}
@@ -466,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory);
this.riskCategory = newValue;
}
// remarks is the audit justification itself, so a change to it is deliberately not recorded
// as an overwrite — requiring remarks in order to write remarks would be circular.
final String remarksParam = "remarks";
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
final String newValue = command.stringValueOfParameterNamed(remarksParam);
@@ -551,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(param, current)) {
final String newValue = command.stringValueOfParameterNamed(param);
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
}
}
@@ -560,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInLocalDateParameterNamed(param, current)) {
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue);
}
}
@@ -570,6 +591,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue);
}
}
@@ -580,10 +602,40 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue);
}
}
/**
* Notes that {@code param} replaced a value the record already held. A field going from empty to
* populated is the record being completed rather than altered, so it is deliberately not
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
*/
private void recordOverwrite(final String param, final Object currentValue) {
final boolean wasPopulated = currentValue instanceof String text
? StringUtils.isNotBlank(text)
: currentValue != null;
if (wasPopulated) {
this.fieldsOverwrittenByLastUpdate.add(param);
}
}
/**
* Parameters whose already-populated value the most recent {@link #update} replaced.
*
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
* still has to save the KYC document details afterwards. Those fields are empty at that point,
* so filling them in leaves this set empty and no audit justification is demanded — while
* changing a document number that was already on the verified record does populate it.
*
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
*/
public Set<String> getFieldsOverwrittenByLastUpdate() {
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
}
/** Called when documents are submitted and 3rd-party verification is initiated. */
public void markInProgress() {
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
@Column(name = "looked_up_on", nullable = false)
private OffsetDateTime lookedUpOn;
// ── Officer decision on a non-clean result ────────────────────────────────
// A DIP hit is a fraud-risk signal rather than a legal bar, so onboarding may continue — but
// the officer has to say why in writing, and that reasoning is kept with the lookup.
/** PROCEEDED | REJECTED. Null while no decision is required or none has been taken. */
@Column(name = "override_decision", length = 20)
private String overrideDecision;
@Column(name = "override_reason")
private String overrideReason;
@Column(name = "decided_by")
private Long decidedBy;
@Column(name = "decided_on")
private OffsetDateTime decidedOn;
public static final String STATUS_CLEAN = "CLEAN";
public static final String STATUS_ERROR = "ERROR";
public static final String STATUS_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE";
public static final String DECISION_PROCEEDED = "PROCEEDED";
public static final String DECISION_REJECTED = "REJECTED";
protected MobileLookupLog() {}
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
this.clientId = clientId;
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
}
public void recordDecision(final String decision, final String reason, final Long userId) {
this.overrideDecision = decision;
this.overrideReason = reason;
this.decidedBy = userId;
this.decidedOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** True when the lookup produced something the officer must consciously decide about. */
public boolean requiresDecision() {
return this.suspected || this.disconnected;
}
/**
* A number may be used only when it came back clean, or when an officer explicitly chose to
* proceed. An unreachable DoT service is NOT a pass — it means nothing was checked.
*/
public boolean isUsable() {
if (STATUS_CLEAN.equals(this.lookupStatus)) {
return true;
}
return DECISION_PROCEEDED.equals(this.overrideDecision);
}
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.mobilelookup.domain;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -35,4 +37,29 @@ public interface MobileLookupLogRepository extends JpaRepository<MobileLookupLog
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
/** Non-clean lookups still waiting for an officer's decision — the DIP review queue. */
@Query("SELECT l FROM MobileLookupLog l WHERE (l.suspected = true OR l.disconnected = true) "
+ "AND l.overrideDecision IS NULL ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findPendingDecisions();
/**
* Most recent lookup for a number, used by the server-side gate to confirm the browser's
* claim that a number was verified.
*
* <p>
* Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller
* takes the first row.
* </p>
*/
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findLatestByMobileNumber(@Param("mobileNumber") String mobileNumber, Pageable pageable);
/**
* Earlier rejections of the same number, on any client. A number turned away once is a
* standing signal: without this the next officer screens it fresh and sees no history.
*/
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber "
+ "AND l.overrideDecision = 'REJECTED' ORDER BY l.decidedOn DESC")
List<MobileLookupLog> findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable);
}
@@ -61,6 +61,65 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
@Column(name = "screened_on", nullable = false)
private OffsetDateTime screenedOn;
// ── Alert disposition ─────────────────────────────────────────────────────
// A FLAGGED screening opens a case. It stays PENDING_REVIEW until an officer either
// documents it as a different person (FALSE_POSITIVE, effective only once the Principal
// Officer approves) or confirms it (CONFIRMED_MATCH, which freezes and gets reported).
@Column(name = "disposition", length = 30)
private String disposition;
@Column(name = "disposition_reason")
private String dispositionReason;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_on")
private OffsetDateTime reviewedOn;
@Column(name = "approved_by")
private Long approvedBy;
@Column(name = "approved_on")
private OffsetDateTime approvedOn;
@Column(name = "fiu_str_reference", length = 100)
private String fiuStrReference;
@Column(name = "nodal_officer_informed_on")
private OffsetDateTime nodalOfficerInformedOn;
@Column(name = "frozen_account_ids", length = 1000)
private String frozenAccountIds;
@Column(name = "frozen_on")
private OffsetDateTime frozenOn;
// ── STR tracking ──────────────────────────────────────────────────────────
// A confirmed match is reportable to FIU-IND. Tracked here rather than as an AML alert: an
// AML alert is built around a monetary transaction, whereas this is triggered by who the
// customer is, so the two are surfaced as separate streams on the FIU screen.
/** NULL until approval; then PENDING_FILING → FILED. */
@Column(name = "str_status", length = 30)
private String strStatus;
@Column(name = "str_due_date")
private java.time.LocalDate strDueDate;
@Column(name = "str_raised_on")
private OffsetDateTime strRaisedOn;
public static final String STR_PENDING_FILING = "PENDING_FILING";
public static final String STR_FILED = "FILED";
public static final String RESULT_FLAGGED = "FLAGGED";
public static final String RESULT_CLEAR = "CLEAR";
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
public static final String DISPOSITION_FALSE_POSITIVE = "FALSE_POSITIVE";
public static final String DISPOSITION_CONFIRMED = "CONFIRMED_MATCH";
protected SanctionsScreeningLog() {}
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
this.clientId = clientId;
this.ipAddress = ipAddress;
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
// A hit opens a case; a clear screening has no case to close.
this.disposition = RESULT_FLAGGED.equals(screeningResult) ? DISPOSITION_PENDING : null;
}
// ── Alert lifecycle ───────────────────────────────────────────────────────
/**
* Records the officer's assessment of the hit. Deliberately does NOT clear the block: a
* false positive only takes effect once {@link #approve(Long)} is called by the Principal
* Officer, so the person who raised it cannot wave their own match through.
*/
public void recordDisposition(final String newDisposition, final String reason, final Long userId) {
this.disposition = newDisposition;
this.dispositionReason = reason;
this.reviewedBy = userId;
this.reviewedOn = DateUtils.getOffsetDateTimeOfTenant();
// Re-opening a previously approved alert must drop the old approval with it.
this.approvedBy = null;
this.approvedOn = null;
}
public void approve(final Long approverId) {
this.approvedBy = approverId;
this.approvedOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
if (strReference != null) {
this.fiuStrReference = strReference;
// Recording the FIU-IND acknowledgement is what closes the filing obligation, so the
// case stops appearing as outstanding on the FIU screen.
if (!strReference.isBlank()) {
this.strStatus = STR_FILED;
}
}
if (nodalOfficerInformedOn != null) {
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
}
}
public void recordFreeze(final String accountIds) {
this.frozenAccountIds = accountIds;
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
}
/**
* Opens the STR obligation on approval of a confirmed match. PMLA allows seven working days
* from forming the suspicion; seven calendar days is the stricter reading.
*/
public void raiseStr() {
if (this.strStatus != null) {
return;
}
this.strStatus = STR_PENDING_FILING;
this.strRaisedOn = DateUtils.getOffsetDateTimeOfTenant();
this.strDueDate = DateUtils.getBusinessLocalDate().plusDays(7);
}
public boolean isStrPending() { return STR_PENDING_FILING.equals(this.strStatus); }
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
public boolean isApprovedFalsePositive() {
return DISPOSITION_FALSE_POSITIVE.equals(this.disposition) && this.approvedBy != null;
}
public boolean isConfirmedMatch() { return DISPOSITION_CONFIRMED.equals(this.disposition); }
// ── Accessors ─────────────────────────────────────────────────────────────
public String getScreenedName() { return screenedName; }
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
public void setClientId(Long clientId) { this.clientId = clientId; }
public String getIpAddress() { return ipAddress; }
public OffsetDateTime getScreenedOn() { return screenedOn; }
public String getDisposition() { return disposition; }
public String getDispositionReason() { return dispositionReason; }
public Long getReviewedBy() { return reviewedBy; }
public OffsetDateTime getReviewedOn() { return reviewedOn; }
public Long getApprovedBy() { return approvedBy; }
public OffsetDateTime getApprovedOn() { return approvedOn; }
public String getFiuStrReference() { return fiuStrReference; }
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
public String getFrozenAccountIds() { return frozenAccountIds; }
public OffsetDateTime getFrozenOn() { return frozenOn; }
public String getStrStatus() { return strStatus; }
public java.time.LocalDate getStrDueDate() { return strDueDate; }
public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
}
@@ -39,4 +39,32 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findByDisposition(@Param("disposition") String disposition);
/** Every open alert — the Principal Officer's review queue. */
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition IS NOT NULL ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findAllAlerts();
/**
* A name is only cleared to onboard once a false positive on that exact name has been
* approved by the Principal Officer — the maker's own assessment is not enough.
*/
@Query("SELECT COUNT(l) FROM SanctionsScreeningLog l WHERE LOWER(TRIM(l.screenedName)) = LOWER(TRIM(:name)) "
+ "AND l.disposition = 'FALSE_POSITIVE' AND l.approvedBy IS NOT NULL")
long countApprovedFalsePositives(@Param("name") String name);
/** Latest alert for a client, used to decide whether their accounts must stay frozen. */
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
+ "ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
/**
* Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
* Pass null for every STR regardless of whether it has been filed.
*/
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
+ "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
List<SanctionsScreeningLog> findStrCases(@Param("status") String status);
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.infrastructure.contentstore.policy;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Strings;
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
@Component
public class MimeContentPolicy implements ContentPolicy {
// CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately
// detects it as text/plain even though it was uploaded (and named) as text/csv.
private static final Set<String> INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain");
private final ContentDetectorManager contentDetectorManager;
@Override
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
var result = contentDetectorManager
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) {
boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType())
|| (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType())
&& INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType()));
if (!matches) {
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
result.getMimeType(), ctx.getMimeType()));
}
@@ -0,0 +1,131 @@
/**
* 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.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
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.Date;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
* is, by construction, a real dividend payment.
*/
@Path("/v1/accounting/dividend-paid-report")
@Component
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
@RequiredArgsConstructor
public class DividendPaidReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final JsonObject result = new JsonObject();
result.addProperty("fromDate", fromDate);
result.addProperty("toDate", toDate);
if (payableDividendsAccountId == null) {
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
result.addProperty("totalPaid", BigDecimal.ZERO);
result.addProperty("payoutCount", 0);
result.add("entries", new JsonArray());
result.addProperty("glAccountConfigured", false);
return gson.toJson(result);
}
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
SELECT entry_date, amount, transaction_id
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
ORDER BY entry_date DESC
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
result.addProperty("glAccountConfigured", true);
final JsonArray entries = new JsonArray();
for (final Map<String, Object> row : entryRows) {
final JsonObject entry = new JsonObject();
entry.addProperty("entryDate", row.get("entry_date").toString());
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("transactionId", (String) row.get("transaction_id"));
entries.add(entry);
}
result.add("entries", entries);
return gson.toJson(result);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,98 @@
/**
* 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.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
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.Date;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Real Profit &amp; Loss (Net Profit) for a date range, computed live from posted General Ledger
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
*
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
* and decreases with a CREDIT. Reversed entries are excluded.
*/
@Path("/v1/accounting/profitandloss")
@Component
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
@RequiredArgsConstructor
public class ProfitAndLossApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
"netProfit", netProfit));
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return false;
}
@Override
public boolean isMakerCheckerEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
}
@Override
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -0,0 +1,114 @@
/**
* 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.infrastructure.databaseexport.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
import org.springframework.stereotype.Component;
@Path("/v1/databaseexport")
@Component
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
@RequiredArgsConstructor
public class DatabaseExportApiResource {
private final DatabaseExportService service;
private final FromJsonHelper fromJsonHelper;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List export requests, optionally filtered by status")
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
return this.service.retrieveAll(status);
}
@GET
@Path("{requestId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a single export request")
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
return this.service.retrieveOne(requestId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Raise a request for a full database export")
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
final Long id = this.service.requestExport(reason);
return Map.of("resourceId", id);
}
@POST
@Path("{requestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve an export request (admin only, never the requester)")
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
this.service.approve(requestId);
return Map.of("resourceId", requestId, "status", "APPROVED");
}
@POST
@Path("{requestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject an export request with a reason")
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
? json.get("rejectionReason").getAsString()
: null;
this.service.reject(requestId, reason);
return Map.of("resourceId", requestId, "status", "REJECTED");
}
@GET
@Path("{requestId}/download")
@Produces({ "application/zip" })
@Operation(summary = "Download the approved export as a ZIP of CSV files")
public Response download(@PathParam("requestId") final Long requestId) {
final byte[] archive = this.service.generateExport(requestId);
return Response.ok(archive)
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
.header("Content-Length", archive.length)
.build();
}
}
@@ -0,0 +1,152 @@
/**
* 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.infrastructure.databaseexport.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
*/
@Entity
@Table(name = "m_database_export_request")
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
/** Approved and the dump has actually been downloaded at least once. */
public static final String STATUS_GENERATED = "GENERATED";
@Column(name = "status", length = 30, nullable = false)
private String status;
@Column(name = "reason", nullable = false)
private String reason;
@Column(name = "requested_by", nullable = false)
private Long requestedBy;
@Column(name = "requested_by_username", length = 100)
private String requestedByUsername;
@Column(name = "requested_on", nullable = false)
private LocalDateTime requestedOn;
@Column(name = "approved_by")
private Long approvedBy;
@Column(name = "approved_by_username", length = 100)
private String approvedByUsername;
@Column(name = "approved_on")
private LocalDateTime approvedOn;
@Column(name = "rejection_reason")
private String rejectionReason;
@Column(name = "generated_on")
private LocalDateTime generatedOn;
@Column(name = "generated_by")
private Long generatedBy;
@Column(name = "file_name", length = 255)
private String fileName;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "row_count")
private Long rowCount;
protected DatabaseExportRequest() {
// Default constructor for JPA
}
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
final LocalDateTime requestedOn) {
this.status = STATUS_PENDING_APPROVAL;
this.reason = reason;
this.requestedBy = requestedBy;
this.requestedByUsername = requestedByUsername;
this.requestedOn = requestedOn;
}
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
final LocalDateTime requestedOn) {
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
}
public String getStatus() {
return this.status;
}
public Long getRequestedBy() {
return this.requestedBy;
}
public boolean isPendingApproval() {
return STATUS_PENDING_APPROVAL.equals(this.status);
}
/**
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
* second copy has to be asked for and approved again — so every copy of the database that exists outside
* the system can be traced back to an approval that was granted for it specifically.
*/
public boolean isDownloadable() {
return STATUS_APPROVED.equals(this.status);
}
public boolean isAlreadyDownloaded() {
return STATUS_GENERATED.equals(this.status);
}
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
this.status = STATUS_APPROVED;
this.approvedBy = approvedBy;
this.approvedByUsername = approvedByUsername;
this.approvedOn = approvedOn;
}
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
final String rejectionReason) {
this.status = STATUS_REJECTED;
this.approvedBy = approvedBy;
this.approvedByUsername = approvedByUsername;
this.approvedOn = approvedOn;
this.rejectionReason = rejectionReason;
}
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
final Long fileSizeBytes, final Long rowCount) {
this.status = STATUS_GENERATED;
this.generatedBy = generatedBy;
this.generatedOn = generatedOn;
this.fileName = fileName;
this.fileSizeBytes = fileSizeBytes;
this.rowCount = rowCount;
}
}
@@ -0,0 +1,42 @@
/**
* 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.infrastructure.databaseexport.domain;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
long countByRequestedByAndStatus(Long requestedBy, String status);
/**
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
*/
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
}
@@ -0,0 +1,42 @@
/**
* 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.infrastructure.databaseexport.service;
import java.util.List;
import java.util.Map;
/** Requesting, approving and generating a full export of the tenant database. */
public interface DatabaseExportService {
Long requestExport(String reason);
List<Map<String, Object>> retrieveAll(String status);
Map<String, Object> retrieveOne(Long requestId);
void approve(Long requestId);
void reject(Long requestId, String rejectionReason);
/**
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
* admin has already approved.
*/
byte[] generateExport(Long requestId);
}
@@ -0,0 +1,318 @@
/**
* 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.infrastructure.databaseexport.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class DatabaseExportServiceImpl implements DatabaseExportService {
private static final String PERMISSION = "DATABASEEXPORT";
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
private static final int MAX_ROWS_PER_TABLE = 200_000;
private final PlatformSecurityContext context;
private final DatabaseExportRequestRepository repository;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
public Long requestExport(final String reason) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_" + PERMISSION);
if (reason == null || reason.trim().length() < 10) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
"A reason of at least 10 characters is required for a database export request.");
}
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
// approver to rubber-stamp in bulk.
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
"You already have a database export request awaiting approval.");
}
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
LocalDateTime.now());
this.repository.saveAndFlush(request);
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
return request.getId();
}
@Override
public List<Map<String, Object>> retrieveAll(final String status) {
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
// client `requestedbyusername` and leave every camelCase field on the screen empty.
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
+ "FROM m_database_export_request ");
if (status != null && !status.trim().isEmpty()) {
sql.append("WHERE status = ? ORDER BY id DESC");
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
}
sql.append("ORDER BY id DESC");
return this.jdbcTemplate.queryForList(sql.toString());
}
@Override
public Map<String, Object> retrieveOne(final Long requestId) {
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
+ "FROM m_database_export_request WHERE id = ?",
requestId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
}
return rows.get(0);
}
@Transactional
@Override
public void approve(final Long requestId) {
final AppUser approver = this.context.authenticatedUser();
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
final DatabaseExportRequest request = findOneOrThrow(requestId);
validatePending(request);
validateNotSelfApproval(request, approver);
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
this.repository.saveAndFlush(request);
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
}
@Transactional
@Override
public void reject(final Long requestId, final String rejectionReason) {
final AppUser approver = this.context.authenticatedUser();
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
final DatabaseExportRequest request = findOneOrThrow(requestId);
validatePending(request);
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
"A reason is required when rejecting a database export request.");
}
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
this.repository.saveAndFlush(request);
}
@Transactional
@Override
public byte[] generateExport(final Long requestId) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
// calls must not both find it unspent.
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
"Export request not found."));
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
// being turned away with a message about approval that no longer describes their situation.
if (request.isAlreadyDownloaded()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
"This export has already been downloaded. Raise a new request to take another copy.");
}
if (!request.isDownloadable()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
"This export request has not been approved. Current status: " + request.getStatus() + ".");
}
validateIsRequester(request, user);
final List<String> tables = listTenantTables();
long totalRows = 0;
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
for (final String table : tables) {
try {
totalRows += writeTableAsCsv(zip, table);
} catch (final Exception e) {
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
log.error("Database export: skipping table {} — {}", table, e.getMessage());
}
}
writeManifest(zip, request, tables, totalRows, user);
} catch (final IOException e) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
"Failed to generate the database export: " + e.getMessage());
}
final byte[] archive = buffer.toByteArray();
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
+ ".zip";
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
this.repository.saveAndFlush(request);
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
user.getId(), tables.size(), totalRows, archive.length);
return archive;
}
private List<String> listTenantTables() {
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
}
/** Writes one table as a CSV entry and returns how many rows it contained. */
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
// The table name comes from information_schema, never from user input, so it cannot carry injection.
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
if (rows.isEmpty()) {
// Still emit the header so an empty table is visibly empty rather than missing.
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
zip.write('\n');
zip.closeEntry();
return 0;
}
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
zip.write('\n');
for (final Map<String, Object> row : rows) {
final StringBuilder line = new StringBuilder();
for (int i = 0; i < columns.size(); i++) {
if (i > 0) {
line.append(',');
}
line.append(csvEscape(row.get(columns.get(i))));
}
line.append('\n');
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
}
zip.closeEntry();
return rows.size();
}
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
final long totalRows, final AppUser downloadedBy) throws IOException {
final Map<String, Object> details = retrieveOne(request.getId());
final StringBuilder manifest = new StringBuilder();
manifest.append("CBSNexora — Full Database Export\n");
manifest.append("=================================\n\n");
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
manifest.append("Handle under your institution's data protection policy.\n\n");
manifest.append("Request ID : ").append(request.getId()).append('\n');
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
manifest.append("Reason : ").append(details.get("reason")).append('\n');
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
manifest.append("Tables exported : ").append(tables.size()).append('\n');
manifest.append("Rows exported : ").append(totalRows).append('\n');
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
private String csvEscape(final Object value) {
if (value == null) {
return "";
}
final String text = String.valueOf(value);
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
return '"' + text.replace("\"", "\"\"") + '"';
}
return text;
}
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
"error.msg.databaseexport.not.found", "Export request not found."));
}
private void validatePending(final DatabaseExportRequest request) {
if (!request.isPendingApproval()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
"This request has already been actioned. Current status: " + request.getStatus() + ".");
}
}
/**
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
* approver download would collapse the two-person control back into one.
*/
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
if (!user.getId().equals(request.getRequestedBy())) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
"Only the user who raised this export request can download the archive.");
}
}
/** The entire control is that two different people are involved, so this is the check that enforces it. */
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
if (approver.getId().equals(request.getRequestedBy())) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
"A database export cannot be approved by the same user who requested it.");
}
}
}
@@ -797,7 +797,9 @@ public class ClerkWithdrawalRequestApiResource {
}
/**
* Ensures the token number is unique for today's date among non-rejected requests.
* Ensures the token number is unique for today's date among non-rejected requests, checking
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
* {@code excludeId} is set during resubmit to skip the record being edited.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
sql.append(" AND id <> ?");
params.add(excludeId);
}
final long count = Optional.ofNullable(
final long ownCount = Optional.ofNullable(
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
).orElse(0L);
if (count > 0) {
final long otherCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + otherCount > 0) {
throw apiError(400,
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
}
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
private final SecureRandom secureRandom = new SecureRandom();
@Autowired
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
final EmailTemplateService emailTemplateService) {
final EmailTemplateService emailTemplateService,
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
this.restTemplate = new RestTemplate();
this.otpLogRepository = otpLogRepository;
this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService;
this.mobileLookupService = mobileLookupService;
}
// ── Generate ─────────────────────────────────────────────────────────────
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
}
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
// credential, so this is refused at the point of dispatch rather than merely warned about
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder().success(false)
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
+ "Contact the customer through an alternate channel and obtain a new number.")
.build();
}
final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
log.setProvider(PROVIDER_LINCPAY);
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
authorizedSignatoryParamName, additionalInfoParamName, dateOfRegistrationParamName, datatables));
authorizedSignatoryParamName, authorizedSignatoryClientIdsParamName, additionalInfoParamName,
dateOfRegistrationParamName, datatables));
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
@@ -70,8 +71,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
dateOfRegistrationParamName));
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
/**
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
@@ -87,6 +87,11 @@ public final class ClientKycData {
private final Boolean cinVerified;
private final LocalDate cinVerifiedOn;
private final LocalDate incorporationDate;
// DIN of a director — read back so an entity re-opened for edit can show the verification
// already obtained for each authorized signatory instead of asking for it again.
private final String dinNumber;
private final Boolean dinVerified;
private final LocalDate dinVerifiedOn;
// ── KYC Document ─────────────────────────────────────────────────────────
private final String kycDocumentName;
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
@Column(name = "authorized_signatory", length = 500, nullable = true)
private String authorizedSignatory;
/**
* Client ids of the signatories named in {@link #authorizedSignatory}, comma separated and in the
* same order. Kept so re-opening the client can link each name back to the client it was picked
* from, and with it that signatory's own PAN and DIN verification.
*/
@Column(name = "authorized_signatory_client_ids", length = 500, nullable = true)
private String authorizedSignatoryClientIds;
@Column(name = "additional_info", length = 500, nullable = true)
private String additionalInfo;
@@ -99,9 +107,9 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) {
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
additionalInfo, dateOfRegistration);
additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
}
protected ClientNonPerson() {
@@ -110,7 +118,7 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
final LocalDate dateOfRegistration) {
final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
if (client != null) {
this.client = client;
}
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.authorizedSignatory = authorizedSignatory.trim();
}
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
}
if (StringUtils.isNotBlank(additionalInfo)) {
this.additionalInfo = additionalInfo.trim();
}
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName,
this.authorizedSignatoryClientIds)) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName);
actualChanges.put(ClientApiConstants.authorizedSignatoryClientIdsParamName, newValue);
this.authorizedSignatoryClientIds = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
@@ -48,7 +48,10 @@ public class KycVerificationResult {
.build();
}
/** Convenience factory for a failed verification. */
/**
* The provider answered and rejected the document — an unknown CIN, a name that does not match.
* A data problem the officer can act on, not an outage.
*/
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
return KycVerificationResult.builder()
.status(Status.FAILURE)
@@ -57,7 +60,25 @@ public class KycVerificationResult {
.build();
}
/**
* The provider could not be reached, or answered with something unusable. Nothing was learnt
* about the document, so callers should report this as the service being unavailable rather
* than as the document having failed its check.
*/
public static KycVerificationResult error(final String errorMessage, final String rawResponse) {
return KycVerificationResult.builder()
.status(Status.ERROR)
.errorMessage(errorMessage)
.rawResponse(rawResponse)
.build();
}
public boolean isSuccess() {
return Status.SUCCESS == this.status;
}
/** Distinguishes an unreachable provider from one that answered with a rejection. */
public boolean isServiceError() {
return Status.ERROR == this.status;
}
}
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
final JsonObject response = parseJson(rawResponse);
if (response == null) {
return KycVerificationResult.failure(
// Unparseable body: nothing was learnt about the CIN, so this is the provider
// misbehaving rather than the CIN being bad.
return KycVerificationResult.error(
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
rawResponse);
}
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
} catch (final HttpClientErrorException | HttpServerErrorException e) {
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
e.getStatusCode(), e.getResponseBodyAsString());
return KycVerificationResult.failure(
return KycVerificationResult.error(
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
+ e.getResponseBodyAsString(), null);
} catch (final Exception e) {
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
return KycVerificationResult.failure("CIN verification failed: " + e.getMessage(), null);
return KycVerificationResult.error("CIN verification failed: " + e.getMessage(), null);
}
}
@@ -34,7 +34,7 @@ import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Mapper(config = MapstructMapperConfig.class)
public interface ClientMapper {
@@ -107,6 +107,11 @@ public interface ClientMapper {
@Mapping(target = "legalFormId", ignore = true)
@Mapping(target = "clientCollateralManagements", ignore = true)
@Mapping(target = "groups", ignore = true)
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
// ClientReadPlatformServiceImpl#retrieveOne.
@Mapping(target = "dipLiveStatus", ignore = true)
@Mapping(target = "alternateDipLiveStatus", ignore = true)
@Mapping(target = "sanctionsAlertStatus", ignore = true)
ClientData map(Client source);
@Named("clientTypeCode")
@@ -107,6 +107,9 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.cinVerified(kyc.isCinVerified())
.cinVerifiedOn(kyc.getCinVerifiedOn())
.incorporationDate(kyc.getIncorporationDate())
.dinNumber(kyc.getDinNumber())
.dinVerified(kyc.isDinVerified())
.dinVerifiedOn(kyc.getDinVerifiedOn())
.kycDocumentName(kyc.getKycDocumentName())
.kycDocumentNumber(kyc.getKycDocumentNumber())
.kycDocIssueDate(kyc.getKycDocIssueDate())
@@ -93,9 +93,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType");
final boolean isIndividualInitiate = kycTypeOnInitiate != null && kycTypeOnInitiate == 1;
enforceNonIndividualOnlyFields(command, isIndividualInitiate);
enforceNonIndividualOnlyFields(command, isIndividualClient(clientForValidation));
if (kycRepositoryWrapper.existsByClientId(clientId)) {
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
@@ -140,23 +138,29 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
enforceNonIndividualOnlyFields(command, isIndividualExisting);
enforceNonIndividualOnlyFields(command, isIndividualClient(client));
final ClientKycStatus currentStatus = kyc.getKycStatus();
// When re-editing a VERIFIED record, remarks are mandatory (audit trail)
if (currentStatus.isVerified()) {
final Map<String, Object> changes = kyc.update(command);
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
// request that replaces data the record already held. A non-individual KYC auto-verifies as
// soon as PAN and GSTIN pass, which lands part-way through the create wizard, and the
// officer still has to save the KYC document details afterwards; filling in fields that were
// empty is completing the record, not altering it, and must not demand a justification.
// Thrown before the save below, and this method is @Transactional, so the in-memory
// mutations from update() are rolled back.
if (currentStatus.isVerified() && !kyc.getFieldsOverwrittenByLastUpdate().isEmpty()) {
final String remarks = command.stringValueOfParameterNamed("remarks");
if (StringUtils.isBlank(remarks)) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"error.msg.client.kyc.remarks.required.for.reverification",
"Remarks are mandatory when updating a verified KYC record.", "remarks")));
"Remarks are mandatory when changing data already recorded on a verified KYC record.",
"remarks")));
}
}
final Map<String, Object> changes = kyc.update(command);
if (!changes.isEmpty()) {
kycRepositoryWrapper.saveAndFlush(kyc);
}
@@ -335,7 +339,11 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
// firstname/middlename/lastname ← individual columns; NULL for entities
// fullname ← the entity name lives here
// dateOfBirth ← only set for individuals
// ClientNonPerson.dateOfRegistration ← the incorporation date for entities
// ClientKyc.incorporationDate ← the incorporation date for entities, captured on the
// KYC step next to the entity PAN it is verified against
// ClientNonPerson.dateOfRegistration ← where that date lived for clients created before
// the field moved to the KYC step; read as a fallback
// so those older entities still verify.
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
@@ -343,10 +351,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final String dob;
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null;
dob = regDate != null
? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
java.time.LocalDate incorpDate = kyc.getIncorporationDate();
if (incorpDate == null) {
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
incorpDate = (np != null) ? np.getDateOfRegistration() : null;
}
dob = incorpDate != null
? incorpDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
: null;
} else {
dob = client.dateOfBirth() != null
@@ -521,15 +532,36 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
"CIN number is not set on this KYC record. Update the KYC record first.");
}
final KycVerificationResult result = kycVerificationService.verifyCin(kyc.getCinNumber());
final String cin = kyc.getCinNumber().toUpperCase().trim();
if (!cin.matches(ClientKycApiConstants.CIN_REGEX)) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"error.msg.client.kyc.cin.invalid.format",
"Invalid CIN format. Expected 21 characters, e.g. L17110MH1973PLC019786.",
ClientKycApiConstants.cinNumberParam)));
}
if (!result.isSuccess()) {
final KycVerificationResult result = kycVerificationService.verifyCin(cin);
if (!result.isSuccess() && result.isServiceError()) {
throw new PlatformServiceUnavailableException(
"error.msg.client.kyc.cin.verification.failed",
"CIN verification failed: " + result.getErrorMessage(),
result.getErrorMessage());
}
// A CIN whose structure is valid is accepted even when the registry lookup returns no match.
// The provider's company endpoint answers "Not Found" for every CIN put to it, including
// ones that are demonstrably real, so a negative from it says nothing about the company and
// cannot be allowed to block onboarding. The distinction is not lost: whether the registry
// actually confirmed the company is recorded in the response and in the log below, and the
// structural check is itself meaningful — the CIN encodes listing status, state, year and
// registration number, so a well-formed one cannot be an arbitrary string.
final boolean confirmedByRegistry = result.isSuccess();
if (!confirmedByRegistry) {
LOG.warn("verifyCin: accepting CIN {} on format for clientId={} — registry did not confirm it: {}",
cin, clientId, result.getErrorMessage());
}
kyc.markCinVerified();
kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
@@ -537,6 +569,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("cinVerified", true);
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
changes.put("cinConfirmedByRegistry", confirmedByRegistry);
if (!confirmedByRegistry) {
changes.put("cinVerificationMessage", "CIN format validated. MCA records could not confirm this company.");
}
changes.put("kycStatus", kyc.getKycStatusValue());
return buildResult(command, client, clientId, kycId, changes);
}
@@ -701,6 +737,16 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
}
/**
* Whether the client itself is a natural person. Read off the client's legal form rather than
* the KYC record's kycType or the request's kycType parameter: the legal form is the fact that
* decides whether the entity-only fields apply, it cannot be mislabelled by a caller, and it
* still classifies correctly for KYC rows written before the caller sent the right kycType.
*/
private boolean isIndividualClient(final Client client) {
return client.getLegalForm() == null || !LegalForm.fromInt(client.getLegalForm()).isEntity();
}
/**
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
@@ -976,11 +1022,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting);
enforceNonIndividualOnlyFields(jsonCommand, isIndividualClient(client));
final ClientKycStatus currentStatus = kyc.getKycStatus();
final Map<String, Object> changes = kyc.update(jsonCommand);
@@ -79,10 +79,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
private final ColumnValidator columnValidator;
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final ClientMapper clientMapper;
@Override
@@ -227,6 +230,24 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
final ClientData clientData = clientMapper.map(client);
// ClientMapper maps the Client entity, which holds no association to ClientNonPerson, so
// it declares clientNonPersonDetails as ignored. Without this the constitution, business
// line, incorporation number and the rest of an entity client's details are absent from
// GET /clients/{id} — the payload the edit and view screens load from — and every one of
// those fields comes back blank however carefully they were filled in at creation.
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
// dipVerified is the flag captured when the client was onboarded and never changes
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
// reported for fraud or reassigned since then shows as flagged on the client's page,
// instead of the page continuing to display the old "Verified".
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
// Get client collaterals
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
.getCollateralsPerClient(clientId);
@@ -254,6 +275,57 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
}
}
/**
* The entity-only block of a client, or null for an individual (or an entity whose details row
* was never written). Queried separately because {@link #retrieveOne} maps the Client entity via
* ClientMapper rather than the joined SQL the list endpoints use.
*/
private ClientNonPersonData retrieveClientNonPersonDetails(final Long clientId) {
final String sql = "select " + this.clientNonPersonMapper.schema();
final List<ClientNonPersonData> details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR
clientId);
return details.isEmpty() ? null : details.get(0);
}
private static final class ClientNonPersonDataMapper implements RowMapper<ClientNonPersonData> {
public String schema() {
return " cnp.constitution_cv_id as constitutionId, cvConstitution.code_value as constitutionValue,"
+ " cnp.incorp_no as incorpNo, cnp.incorp_validity_till as incorpValidityTill,"
+ " cnp.main_business_line_cv_id as mainBusinessLineId,"
+ " cvMainBusinessLine.code_value as mainBusinessLineValue, cnp.remarks as remarks,"
+ " cnp.authorized_signatory as authorizedSignatory,"
+ " cnp.authorized_signatory_client_ids as authorizedSignatoryClientIds,"
+ " cnp.additional_info as additionalInfo,"
+ " cnp.date_of_registration as dateOfRegistration" + " from m_client_non_person cnp"
+ " left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id"
+ " left join m_code_value cvMainBusinessLine on cvMainBusinessLine.id = cnp.main_business_line_cv_id"
+ " where cnp.client_id = ?";
}
@Override
public ClientNonPersonData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long constitutionId = JdbcSupport.getLong(rs, "constitutionId");
final String constitutionValue = rs.getString("constitutionValue");
final CodeValueData constitution = CodeValueData.instance(constitutionId, constitutionValue);
final Long mainBusinessLineId = JdbcSupport.getLong(rs, "mainBusinessLineId");
final String mainBusinessLineValue = rs.getString("mainBusinessLineValue");
final CodeValueData mainBusinessLine = CodeValueData.instance(mainBusinessLineId, mainBusinessLineValue);
final String incorpNo = rs.getString("incorpNo");
final LocalDate incorpValidityTill = JdbcSupport.getLocalDate(rs, "incorpValidityTill");
final String remarks = rs.getString("remarks");
final String authorizedSignatory = rs.getString("authorizedSignatory");
final String authorizedSignatoryClientIds = rs.getString("authorizedSignatoryClientIds");
final String additionalInfo = rs.getString("additionalInfo");
final LocalDate dateOfRegistration = JdbcSupport.getLocalDate(rs, "dateOfRegistration");
return new ClientNonPersonData(constitution, incorpNo, incorpValidityTill, mainBusinessLine, remarks,
authorizedSignatory, additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
}
}
@Override
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -134,6 +135,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final ClientRepository clientJpaRepository;
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService;
@@ -156,6 +159,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientNonPersonRepository.delete(clientNonPerson);
}
// Safely cascade-delete child records that have foreign keys pointing to this client
this.jdbcTemplate.update("delete from m_client_identifier where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_family_members where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
this.clientRepository.delete(client);
this.clientRepository.flush();
return new CommandProcessingResultBuilder() //
@@ -611,6 +621,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
newClient.setScreeningLogId(screeningLogId);
}
// UNSC sanctions gate. The UI disables Save & Next on a match, but that is a browser
// control only — screen server-side too, so a direct API call cannot onboard a listed
// person. The name is cleared only by a false positive the Principal Officer approved.
validateNotSanctioned(newClient.getDisplayName());
// DIP/MNRL gate — same reasoning: dipVerified arrives from the browser and cannot be
// trusted on its own. Record the status actually on file alongside it.
validateMobileNumberUsable(newClient.getMobileNo(), "mobileNo");
validateMobileNumberUsable(newClient.getAlternateMobileNo(), "alternateMobileNo");
newClient.setDipStatus(this.mobileLookupService.getLatestStatus(newClient.getMobileNo()));
newClient.setAlternateDipStatus(this.mobileLookupService.getLatestStatus(newClient.getAlternateMobileNo()));
// Account Number generation
this.clientRepository.saveAndFlush(newClient);
if (StringUtils.isBlank(accountNo)) {
@@ -756,6 +778,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String authorizedSignatory = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
clientNonPersonElement);
final LocalDate dateOfRegistration = this.fromApiJsonHelper
@@ -763,7 +787,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration);
dateOfRegistration, authorizedSignatoryClientIds);
this.clientNonPersonRepository.save(newClientNonPerson);
}
@@ -884,12 +908,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
oldMobileNoForNotification = oldValue;
newMobileNoForNotification = newValue;
mobileNoChanged = true;
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
// alert here. Left to the officer as a second manual step it would be forgotten,
// and the queue would fill with cases that were in fact already dealt with.
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
@@ -1700,6 +1730,82 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
/*
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
*/
/**
* Refuses onboarding of a name that hits the UN Security Council consolidated list.
*
* <p>
* Applies equally to individuals and non-individuals — the display name covers both, and the
* list itself holds INDIVIDUAL and ENTITY records. The only way past this is a false positive
* on the same name that the Principal Officer has approved, which is deliberately a different
* person's decision from the officer who reviewed the hit.
* </p>
*/
private void validateNotSanctioned(final String displayName) {
if (StringUtils.isBlank(displayName)) {
return;
}
if (this.sanctionsScreeningService.hasApprovedFalsePositive(displayName)) {
return;
}
// Non-logging check on purpose — see SanctionsScreeningService#isNameSanctioned.
if (this.sanctionsScreeningService.isNameSanctioned(displayName)) {
throw new GeneralPlatformDomainRuleException("error.msg.client.sanctions.match",
"'" + displayName + "' matched the UN Security Council Consolidated List. Onboarding is blocked until the "
+ "Principal Officer clears the alert.",
displayName);
}
}
/**
* Refuses a mobile number that the DoT Digital Intelligence Platform flagged, unless an
* officer explicitly recorded a decision to proceed with a written reason.
*
* <p>
* A number with no lookup on record passes: DIP verification is enforced in the onboarding
* flow, and failing here would block every legacy update that merely touches an old client.
* What this stops is the case that matters — a number the DoT has actively flagged being
* saved because the browser said it was fine.
* </p>
*/
/**
* Closes any open DIP/MNRL alert on a number that has just been replaced.
*
* <p>
* For an existing customer a flagged number is resolved by obtaining a new one — at which
* point the old alert describes a number the bank no longer uses. Closing it here keeps the
* decision queue a list of work actually outstanding rather than a growing pile of cases that
* were already handled. Failure is logged, never propagated: the client update itself must
* not fail because a bookkeeping row could not be stamped.
* </p>
*/
private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
return;
}
try {
final String reason = StringUtils.isBlank(newNumber)
? "Number removed from the client record on resolution of the DIP/MNRL alert."
: "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
} catch (Exception e) {
log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
e.getMessage());
}
}
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
if (StringUtils.isBlank(mobileNo)) {
return;
}
if (this.mobileLookupService.isMobileNumberUsable(mobileNo.trim())) {
return;
}
throw new GeneralPlatformDomainRuleException("error.msg.client.mobile.dip.flagged",
"Mobile number " + mobileNo + " is flagged on the DoT Digital Intelligence Platform. Record a decision "
+ "to proceed, with reasons, before saving this client.",
parameter);
}
private void validateParentGroupRulesBeforeClientActivation(Client client) {
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
@@ -0,0 +1,762 @@
/**
* 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.dividenddashboard.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.math.RoundingMode;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
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.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker workflow for a member to claim an unclaimed dividend (see
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
* that was never credited into any savings account). A clerk (maker) records exactly how the
* member wants it split when they visit the branch: straight into an overdue loan, credited to
* their linked savings account, or handed over as cash -- any combination, any leftover amount
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
*
* <p>
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
* which debits whatever GL account is actually configured as that product's Fund Source -- not
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
* that same DEBIT leg is rewritten in place to Dividend Payable (see
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
* entry pair, so the transaction ends up with exactly the entries a real accountant would expect
* -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
* post through, so approval
* posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
* physical hand-over is deferred: the claim's {@code cash_status} becomes
* {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
* redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
* the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
* submit/approve/disburse token flow for ordinary savings withdrawals.
*/
@Path("/v1/clerk/dividend-claim-requests")
@Component
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
@RequiredArgsConstructor
public class DividendClaimRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
"l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
"r.submitted_at", "r.actioned_at" };
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
@GET
@Path("/overdue-loans/{clientId}")
@Produces(MediaType.APPLICATION_JSON)
public String overdueLoans(@PathParam("clientId") final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
row.addProperty("accountNo", (String) loan.get("account_no"));
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
result.add(row);
}
return gson.toJson(result);
}
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submit(final String body) {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
// while a dividend share often works out to paise -- floor to the nearest rupee (never
// trusting the frontend to have already done so) so the denomination breakdown the
// cashier counts at disburse time can always match exactly; any leftover paise is simply
// not claimed here and stays part of the lock's balance for a future claim.
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
.setScale(0, RoundingMode.FLOOR);
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
if (lockId == null) {
throw apiError(400, "lockId is required");
}
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT client_id, savings_account_id, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId)
.stream().findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final Object lockSavingsAccountId = lock.get("savings_account_id");
// The share account isn't required to have a linked savings account up front any more --
// if the member wants a savings credit and none is linked, the clerk supplies which
// account to credit right here.
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
: savingsAccountIdOverride;
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (resolvedSavingsAccountId == null) {
throw apiError(400,
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
}
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
resolvedSavingsAccountId);
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
}
}
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
}
if (total.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (loanId == null) {
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
}
final Map<String, Object> loan = jdbcTemplate
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
}
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
throw apiError(400, "Loan " + loanId + " is not Active");
}
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (tokNo == null || tokNo.isBlank()) {
throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
}
validateTokenUniqueToday(tokNo, null);
}
final Long existingPending = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
if (existingPending != null && existingPending > 0) {
throw apiError(409, "A claim request for this dividend is already pending review.");
}
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_share_dividend_claim_request
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
RETURNING id
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
* Ensures the token number is unique for today's date across both this table and
* {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
* Transaction Entry field, so the two pools must never collide.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
final List<Object> params = new ArrayList<>();
params.add(tokNo);
if (excludeId != null) {
sql.append(" AND id <> ?");
params.add(excludeId);
}
final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
if (total > 0) {
throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
}
}
private BigDecimal parseAmount(final String value) {
if (value == null || value.isBlank()) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(value.trim());
} catch (final NumberFormatException e) {
throw apiError(400, "'" + value + "' is not a valid amount");
}
}
// ── GET /v1/clerk/dividend-claim-requests ── Manager sees claim requests ────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + """
FROM m_share_dividend_claim_request r
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
JOIN m_share_dividend_lock l ON l.id = r.lock_id
WHERE r.status = ?
ORDER BY r.submitted_at ASC
""", status);
for (final Map<String, Object> row : rows) {
final Long clientId = ((Number) row.get("client_id")).longValue();
row.put("has_overdue_loan", hasOverdueLoan(clientId));
}
return gson.toJson(rows);
}
private boolean hasOverdueLoan(final Long clientId) {
final Long count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
""", Long.class, clientId);
return count != null && count > 0;
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/approve ── Manager executes the disbursement ──
@POST
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
}
final Long lockId = ((Number) record.get("lock_id")).longValue();
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
}
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(unclaimedAmount) > 0) {
throw apiError(409,
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
}
final Object claimSavingsAccountId = record.get("savings_account_id");
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final Long loanId = ((Number) record.get("loan_id")).longValue();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", loanApplyAmount);
payload.addProperty("note", note);
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
}
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", savingsApplyAmount);
payload.addProperty("note", note);
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
}
final BigDecimal remainder = unclaimedAmount.subtract(total);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
} else {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
}
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
cash_status = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
final JsonObject response = new JsonObject();
response.addProperty("resourceId", id);
response.addProperty("status", "APPROVED");
response.addProperty("totalDisbursed", total);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
return gson.toJson(response);
}
// ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
/**
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
* disbursing it, instead of the manager handing over cash directly at approval time.
*/
@GET
@Path("/by-token/{token}")
@Produces(MediaType.APPLICATION_JSON)
public String getByToken(@PathParam("token") final String token) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
FROM m_share_dividend_claim_request r
JOIN m_client c ON c.id = r.client_id
WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
ORDER BY r.submitted_at DESC LIMIT 1
""", token);
if (rows.isEmpty()) {
throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
}
final Map<String, Object> row = rows.get(0);
final String status = String.valueOf(row.get("status"));
final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
if ("PENDING".equals(status)) {
throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
}
if ("REJECTED".equals(status)) {
throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
}
if (!"APPROVED".equals(status)) {
throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
}
if ("DISBURSED".equals(cashStatus)) {
throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
}
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
}
return gson.toJson(row);
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
/**
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
* approve time, so this step is purely a physical-cash-handover record with double-disbursement
* protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
*/
@POST
@Path("/{id}/disburse")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String disburse(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
final Map<String, Object> record = jdbcTemplate
.queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
id)
.stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
}
String denominationData = null;
BigDecimal transactionAmount = null;
if (body != null && !body.isBlank()) {
final JsonElement root = fromJsonHelper.parse(body);
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
}
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
}
// cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
// but a claim created before that fix may still carry paise -- floor here too so an older
// claim can still be disbursed in whole-rupee cash rather than being stuck forever.
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
if (disbursableAmount.compareTo(transactionAmount) != 0) {
throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
+ disbursableAmount + " (cash can only be handed over in whole rupees).");
}
final long denomTotal = denominationTotal(denominationData);
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
throw apiError(400,
"Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
}
final int updated = jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET
cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
""", currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
if (updated == 0) {
throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
}
if (denominationData != null && !denominationData.isBlank()) {
final Long clientId = ((Number) record.get("client_id")).longValue();
final Long lockId = ((Number) record.get("lock_id")).longValue();
saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
}
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
}
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
private long denominationTotal(final String denominationData) {
if (denominationData == null || denominationData.isBlank()) {
return 0;
}
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (final Exception e) {
throw apiError(400, "denominationData is not valid JSON");
}
if (denomMap == null) {
return 0;
}
long total = 0;
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) {
continue;
}
final int value;
final int count;
try {
value = Integer.parseInt(parts[1]);
count = entry.getValue().getAsInt();
} catch (final Exception e) {
continue;
}
if (count > 0) {
total += (long) value * count;
}
}
return total;
}
/**
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
* claim request's own id is used as the stable txn identifier for grouping.
*/
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
final String denominationData, final AppUser cashier) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (final Exception e) {
return;
}
if (denomMap == null || denomMap.size() == 0) {
return;
}
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final int count;
try {
count = entry.getValue().getAsInt();
} catch (final Exception e) {
continue;
}
if (count <= 0) {
continue;
}
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) {
continue;
}
final String kind = parts[0];
final int value;
try {
value = Integer.parseInt(parts[1]);
} catch (final Exception e) {
continue;
}
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
Long.class, value, kind);
if (ids.isEmpty()) {
continue;
}
final Long denomId = ids.get(0);
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
""", claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
}
}
/**
* Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
* no real customer payment instrument -- resolves the system-seeded "Dividend Claim
* Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
* would break on any tenant seeded differently.
*/
private Long resolveDividendClaimPaymentTypeId() {
final Long paymentTypeId = jdbcTemplate
.queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
.findFirst().orElse(null);
if (paymentTypeId == null) {
throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
}
return paymentTypeId;
}
/**
* The loan repayment / savings deposit command just posted its own native GL entries assuming
* the money came out of whichever account is that product's actual Fund Source (not always
* literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
* own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
* a second manual entry pair (which would leave that fund-source account appearing twice, once
* debited then immediately credited back -- a confusing "wash" no real accountant would post),
* this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
* ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
* Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
* updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
* the two ids is non-null per call.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null) {
return;
}
if (loanTransactionId != null) {
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
if (savingsTransactionId != null) {
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
}
/**
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
* member's hand.
*/
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long cashAtVaultAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
final String description = "Dividend claim #" + claimRequestId + " disbursed";
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
@POST
@Path("/{id}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be rejected. Current status: " + record.get("status"));
}
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (reason == null || reason.isBlank()) {
throw apiError(400, "Rejection reason is required");
}
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, rejection_reason = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), reason, Timestamp.from(Instant.now()), id);
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.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 a dividend claim request.");
}
}
private Map<String, Object> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
status, clerk_id, clerk_name
FROM m_share_dividend_claim_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
throw new NotFoundException("Dividend claim request not found: " + id);
}
return rows.get(0);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,400 @@
/**
* 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.dividenddashboard.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.GET;
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.LocalDate;
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.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
* progress, full 3-year lock history, and members whose unclaimed dividend (see
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
* to be forfeited to profit after 3 years.
*/
@Path("/v1/clerk/dividend-dashboard")
@Component
@Tag(name = "Dividend Dashboard", description = "Bank Manager view of locked share dividends belonging to members with overdue loan EMIs, with an action to apply the dividend towards the loan.")
@RequiredArgsConstructor
public class DividendDashboardApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final int DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL = 100;
private static final int DIVIDEND_PAYOUT_STATUS_APPROVED = 300;
// ── GET /v1/clerk/dividend-dashboard/summary ─────────────────────────────────
@GET
@Path("/summary")
@Produces(MediaType.APPLICATION_JSON)
public String summary() {
final Map<String, Object> declarationStats = jdbcTemplate.queryForMap("""
SELECT COUNT(*) AS total_declarations,
COUNT(*) FILTER (WHERE status = %d) AS pending_approval_declarations,
COUNT(*) FILTER (WHERE status = %d) AS approved_declarations
FROM m_share_product_dividend_pay_out
""".formatted(DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL, DIVIDEND_PAYOUT_STATUS_APPROVED));
// Approval and disbursement now happen atomically (see
// ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable) -- there's no
// more "posted vs pending vs failed" spread to track at the detail level once approved.
final Map<String, Object> detailStats = jdbcTemplate.queryForMap("""
SELECT COUNT(DISTINCT sa.client_id) AS members_affected,
COUNT(sad.id) AS total_member_dividends,
COALESCE(SUM(sad.amount), 0) AS total_amount
FROM m_share_account_dividend_details sad
JOIN m_share_account sa ON sa.id = sad.account_id
""");
final Map<String, Object> lockStats = jdbcTemplate.queryForMap("""
SELECT COUNT(*) FILTER (WHERE status = 'UNCLAIMED') AS locked_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'UNCLAIMED'), 0) AS locked_amount,
COUNT(*) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL) AS applied_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL), 0) AS applied_amount,
COUNT(*) FILTER (WHERE status = 'FORFEITED') AS forfeited_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'FORFEITED'), 0) AS forfeited_amount,
COUNT(*) FILTER (WHERE status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED'), 0) AS claimed_amount
FROM m_share_dividend_lock
""");
final JsonObject result = new JsonObject();
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
result.addProperty("pendingApprovalDeclarations",
((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
result.addProperty("totalAmount", (BigDecimal) detailStats.get("total_amount"));
result.addProperty("lockedCount", ((Number) lockStats.get("locked_count")).longValue());
result.addProperty("lockedAmount", (BigDecimal) lockStats.get("locked_amount"));
result.addProperty("appliedToLoanCount", ((Number) lockStats.get("applied_count")).longValue());
result.addProperty("appliedToLoanAmount", (BigDecimal) lockStats.get("applied_amount"));
result.addProperty("forfeitedCount", ((Number) lockStats.get("forfeited_count")).longValue());
result.addProperty("forfeitedAmount", (BigDecimal) lockStats.get("forfeited_amount"));
result.addProperty("claimedCount", ((Number) lockStats.get("claimed_count")).longValue());
result.addProperty("claimedAmount", (BigDecimal) lockStats.get("claimed_amount"));
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
/**
* Every dividend ever declared (across every share product), so a manager tracking multiple
* dividends at once can see -- per declaration -- how many members it covers and how far its
* payout has actually progressed, not just whether it was approved.
*/
@GET
@Path("/declarations")
@Produces(MediaType.APPLICATION_JSON)
public String declarations() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT spd.id AS declaration_id, sp.name AS product_name,
spd.dividend_period_start_date, spd.dividend_period_end_date, spd.amount AS declared_amount,
spd.status AS declaration_status, spd.created_date, spd.lastmodified_date AS approved_on,
COUNT(sad.id) AS total_members,
COALESCE(SUM(sad.amount), 0) AS total_distributed_amount
FROM m_share_product_dividend_pay_out spd
JOIN m_share_product sp ON sp.id = spd.product_id
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = spd.id
GROUP BY spd.id, sp.name, spd.dividend_period_start_date, spd.dividend_period_end_date,
spd.amount, spd.status, spd.created_date, spd.lastmodified_date
ORDER BY spd.created_date DESC
""");
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final int declarationStatus = ((Number) row.get("declaration_status")).intValue();
final Timestamp approvedOn = (Timestamp) row.get("approved_on");
final boolean isApproved = declarationStatus == DIVIDEND_PAYOUT_STATUS_APPROVED;
final JsonObject entry = new JsonObject();
entry.addProperty("declarationId", ((Number) row.get("declaration_id")).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
entry.addProperty("periodStartDate", row.get("dividend_period_start_date").toString());
entry.addProperty("periodEndDate", row.get("dividend_period_end_date").toString());
entry.addProperty("declaredAmount", (BigDecimal) row.get("declared_amount"));
entry.addProperty("totalDistributedAmount", (BigDecimal) row.get("total_distributed_amount"));
entry.addProperty("createdDate", row.get("created_date").toString());
entry.addProperty("approvedOn", approvedOn == null ? null : approvedOn.toString());
entry.addProperty("totalMembers", ((Number) row.get("total_members")).longValue());
entry.addProperty("status", isApproved ? "APPROVED" : "PENDING_APPROVAL");
result.add(entry);
}
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
/**
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
* profit). Optional {@code status} query param filters to one.
*/
@GET
@Path("/locks")
@Produces(MediaType.APPLICATION_JSON)
public String locks(@QueryParam("status") final String status) {
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
+ "FROM m_share_dividend_lock l " //
+ "JOIN m_client c ON c.id = l.client_id " //
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
+ (filterByStatus ? "WHERE l.status = ? " : "") //
+ "ORDER BY l.locked_date DESC";
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
: jdbcTemplate.queryForList(sql);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
entry.addProperty("clientName", (String) row.get("client_name"));
final Object savingsAccountId = row.get("savings_account_id");
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("lockedDate", row.get("locked_date").toString());
entry.addProperty("releaseDueDate", row.get("release_due_date").toString());
entry.addProperty("status", (String) row.get("status"));
final Object appliedLoanId = row.get("applied_loan_id");
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
result.add(entry);
}
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard ─────────────────────────────────────────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
l.locked_date, l.release_due_date
FROM m_share_dividend_lock l
JOIN m_client c ON c.id = l.client_id
WHERE l.status = 'UNCLAIMED'
AND EXISTS (
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = l.client_id AND ln.loan_status_id = 300
AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
)
ORDER BY l.locked_date ASC
""");
final JsonArray result = new JsonArray();
for (final Map<String, Object> lock : locks) {
final Long clientId = ((Number) lock.get("client_id")).longValue();
final JsonObject row = new JsonObject();
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
row.addProperty("clientId", clientId);
row.addProperty("clientName", (String) lock.get("client_name"));
final Object savingsAccountId = lock.get("savings_account_id");
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
row.addProperty("lockedDate", lock.get("locked_date").toString());
row.addProperty("releaseDueDate", lock.get("release_due_date").toString());
row.add("overdueLoans", overdueLoansFor(clientId));
result.add(row);
}
return gson.toJson(result);
}
private JsonArray overdueLoansFor(final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
row.addProperty("accountNo", (String) loan.get("account_no"));
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
result.add(row);
}
return result;
}
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
/**
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
* and file a claim. Since the dividend was never credited into any savings account (it's a
* pure "Dividend Payable" GL liability until claimed -- see
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
* native and correct) and then redirects the debit leg it assumed the cash came from to the
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
*/
@POST
@Path("/{lockId}/apply-to-loan")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String applyToLoan(@PathParam("lockId") final Long lockId, final String body) {
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT client_id, currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final JsonElement root = fromJsonHelper.parse(body);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final String amountString = fromJsonHelper.extractStringNamed("amount", root);
if (loanId == null) {
throw apiError(400, "loanId is required");
}
BigDecimal applyAmount;
try {
applyAmount = amountString == null ? null : new BigDecimal(amountString.trim());
} catch (final NumberFormatException e) {
throw apiError(400, "amount must be a valid number");
}
if (applyAmount == null || applyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "amount must be greater than zero");
}
if (applyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "amount cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
final Map<String, Object> loan = jdbcTemplate.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId)
.stream().findFirst().orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", applyAmount);
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
} else {
jdbcTemplate.update("""
UPDATE m_share_dividend_lock
SET status = 'CLAIMED', applied_loan_id = ?, applied_transaction_id = ?, last_modified_date = now()
WHERE id = ?
""", loanId, repaymentResult.getResourceId(), lockId);
}
final JsonObject response = new JsonObject();
response.addProperty("lockId", lockId);
response.addProperty("appliedAmount", applyAmount);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
return gson.toJson(response);
}
/**
* The loan repayment just posted above assumed the money came out of whichever account is
* this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
* Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
* -- it didn't really. Rather than reverse that with a second manual entry pair (which would
* leave that fund-source account appearing twice, once debited then immediately credited back
* -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
* account in place to Dividend Payable, so the transaction ends up with exactly the entries a
* real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
* nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
* Payable financial activity.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null) {
return;
}
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,86 @@
/**
* 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.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every Share Product's dividend declaration that is still awaiting approval — Fineract only
* exposes dividends scoped to one product at a time
* ({@code GET /shareproduct/{productId}/dividend}), so a Bank Manager has no single place to see
* "everything currently waiting on me" across products. This aggregates all
* {@code m_share_product_dividend_pay_out} rows still INITIATED so they can be surfaced on the
* Approval Center (Checker Inbox) alongside the other maker-checker request types. Approval
* itself still goes through Fineract's native {@code approveDividend} command from the
* frontend — this resource only covers the missing cross-product listing.
*/
@Path("/v1/clerk/pending-dividends")
@Component
@Tag(name = "Pending Dividends", description = "Dividend declarations awaiting approval, across all Share Products.")
@RequiredArgsConstructor
public class PendingDividendsApiResource {
private static final int DIVIDEND_PAYOUT_STATUS_INITIATED = 100;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT p.id AS dividend_id, p.product_id, sp.name AS product_name, p.amount,
p.dividend_period_start_date, p.dividend_period_end_date, p.created_date,
(SELECT COUNT(*) FROM m_share_account_dividend_details d WHERE d.dividend_pay_out_id = p.id) AS member_count
FROM m_share_product_dividend_pay_out p
JOIN m_share_product sp ON sp.id = p.product_id
WHERE p.status = ?
ORDER BY p.created_date ASC
""", DIVIDEND_PAYOUT_STATUS_INITIATED);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("dividendId", ((Number) row.get("dividend_id")).longValue());
entry.addProperty("productId", ((Number) row.get("product_id")).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("dividendPeriodStartDate", row.get("dividend_period_start_date").toString());
entry.addProperty("dividendPeriodEndDate", row.get("dividend_period_end_date").toString());
entry.addProperty("declaredOn", row.get("created_date") == null ? null : row.get("created_date").toString());
entry.addProperty("memberCount", ((Number) row.get("member_count")).intValue());
result.add(entry);
}
return gson.toJson(result);
}
}
@@ -0,0 +1,277 @@
/**
* 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.loanoriginationpipeline.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
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 lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationPipelineService;
import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationStageService;
import org.springframework.stereotype.Component;
/**
* REST endpoints for the Loan Origination Pipeline — the sanctioning workflow an application walks
* through before a real loan account is ever opened (Application → KYC/OVD → CIC Credit Bureau Check
* → Field Investigation → Personal Discussion → Credit Committee → Risk Approval → Legal Approval →
* Customer Signing → Cheque/ACH → Post-Disbursement Check → Disbursed).
*
* <pre>
* GET /v1/loan-origination/applications – list (optional branchCode/stage/status filters)
* GET /v1/loan-origination/applications/{applicationNo} – one application, in full
* POST /v1/loan-origination/applications – create from the New Application wizard
* PUT /v1/loan-origination/applications/{applicationNo} – save the whole application back
* POST /v1/loan-origination/applications/seed – one-time seed; no-ops if already seeded
* </pre>
*
* <p>
* Requests/responses are the application object verbatim, so the request body is passed straight
* through as JSON rather than being bound to a DTO. A DTO would have to mirror all ~111 workflow
* fields and would silently drop any field it had not been updated to know about — the exact failure
* this module has to avoid, since the workflow gains fields regularly.
*
* <p>
* Authorisation is a normal authenticated staff session. These endpoints deliberately do not define
* their own permission codes: the pipeline is the sanctioning process for lending, so it is gated by
* the same authenticated-staff check the rest of the loan screens use, and per-stage authority
* (who may sit on the Credit Committee, who signs off Risk/Legal) is a workflow concern enforced on
* the application record itself, not a Fineract entity permission.
*/
@Path("/v1/loan-origination/applications")
@Component
@Tag(name = "Loan Origination Pipeline",
description = "Pre-disbursement sanctioning workflow: KYC/OVD, CIC credit bureau check, Field Investigation, "
+ "Personal Discussion, Credit Committee, Risk & Legal approval, signing and disbursement.")
@RequiredArgsConstructor
public class LoanOriginationPipelineApiResource {
private final PlatformSecurityContext context;
private final LoanOriginationPipelineService service;
private final LoanOriginationStageService stageService;
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List loan origination applications",
description = "Newest first. Filters are optional — the Pipeline screen also filters client-side.")
public String retrieveAll(@QueryParam("branchCode") final String branchCode, @QueryParam("stage") final String stage,
@QueryParam("status") final String status) {
this.context.authenticatedUser();
return this.service.findAllAsJson(branchCode, stage, status);
}
@GET
@Path("{applicationNo}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve one loan origination application in full")
public String retrieveOne(@PathParam("applicationNo") final String applicationNo) {
this.context.authenticatedUser();
return this.service.findOneAsJson(applicationNo);
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a loan origination application",
description = "Body is the application object as built by the New Application wizard.")
public String create(final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.service.create(apiRequestBodyAsJson);
}
@PUT
@Path("{applicationNo}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Save a loan origination application",
description = "Full-object save: the body replaces the stored application, its stage history, "
+ "approvals, field-investigation checks and document checklists.")
public String save(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.service.save(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("seed")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Seed the pipeline once",
description = "Inserts the supplied applications only when the pipeline is empty, so reloading the "
+ "frontend cannot duplicate the book. Returns the number actually inserted.")
public String seed(final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return "{\"seeded\":" + this.service.seed(apiRequestBodyAsJson) + "}";
}
// ─────────────────────────────────────────────────────────────────────────
// Per-stage transitions.
//
// Each of these takes only its own step's inputs (who acted, remarks, a resolution number) and
// lets the server validate the stage's prerequisites against the stored record before applying
// the change. The full-object PUT above still exists for inline field edits; these exist because
// a sanctioning decision should not be something a caller can simply assert. Every one returns
// the updated application, so the client replaces its copy rather than guessing the result.
// ─────────────────────────────────────────────────────────────────────────
@POST
@Path("{applicationNo}/kyc-complete")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Complete KYC & Document Collection",
description = "Requires Aadhaar/PAN verified, income proof, bank statement and address proof submitted, "
+ "income assessed, and every product-specific document submitted.")
public String completeKyc(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.completeKyc(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/cic-complete")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Complete the CIC / credit bureau check",
description = "Requires the borrower's signed consent, the collected bureau charge and an uploaded report.")
public String completeCicCheck(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.completeCicCheck(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/field-investigation/{checkKey}/verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Mark one field investigation check verified",
description = "Requires an assigned officer, GPS coordinates and at least one site photograph "
+ "to already be recorded against the visit.")
public String verifyFieldCheck(@PathParam("applicationNo") final String applicationNo,
@PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.verifyFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/field-investigation/{checkKey}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Mark one field investigation check failed", description = "A reason is mandatory.")
public String rejectFieldCheck(@PathParam("applicationNo") final String applicationNo,
@PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.rejectFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/field-investigation/complete")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Complete Field Investigation",
description = "Requires every applicable check (address, office, plus property when secured and shop "
+ "when the applicant runs one) to be verified.")
public String completeFieldInvestigation(@PathParam("applicationNo") final String applicationNo,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.completeFieldInvestigation(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/personal-discussion/clear")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Clear the personal discussion and proceed")
public String clearPersonalDiscussion(@PathParam("applicationNo") final String applicationNo,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.clearPersonalDiscussion(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/personal-discussion/flag")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Flag a discrepancy and hold the application",
description = "A discrepancy type and notes are both mandatory.")
public String flagDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.flagDiscrepancy(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/personal-discussion/resolve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Resolve a held discrepancy and proceed")
public String resolveDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.resolveDiscrepancy(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/approvals/{field}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Record an approval decision",
description = "`field` is one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval. "
+ "An APPROVED decision is checked against that stage's own checklist — the Credit Committee also "
+ "requires a resolution number and date. A REJECTED decision is always permitted.")
public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/signing")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Record the customer's signing of the loan agreement")
public String markSigned(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.markSigned(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/cheque-ach")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Confirm security cheques received and ACH mandate signed")
public String confirmChequeAch(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.confirmChequeAch(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/disburse")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Disburse the loan",
description = "Requires all four approval levels to stand as approved and the customer to have signed. "
+ "Optionally records the real Fineract loan account created at disbursement.")
public String disburse(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.disburse(applicationNo, apiRequestBodyAsJson);
}
}
@@ -0,0 +1,138 @@
/**
* 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.loanoriginationpipeline.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
/**
* Null-safe accessors for the loan origination application object.
*
* <p>
* The application is handled as JSON rather than a typed entity (see
* {@link LoanOriginationPipelineService} for why), so every read has to tolerate a key being absent,
* explicitly null, or the wrong JSON type — an older application stored before a field existed is a
* normal, expected case, not an error. These helpers turn all three into a sensible default so no
* caller has to repeat the checks.
*/
public final class LoanOriginationJson {
private LoanOriginationJson() {}
public static JsonObject parseObject(final String body) {
if (body == null || body.isBlank()) {
// An action with no inputs (e.g. "mark signed") legitimately posts an empty body.
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(body);
if (!parsed.isJsonObject()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.invalid",
"Request body must be a JSON object.");
}
return parsed.getAsJsonObject();
}
public static boolean present(final JsonObject obj, final String key) {
return obj != null && obj.has(key) && !obj.get(key).isJsonNull();
}
public static String str(final JsonObject obj, final String key) {
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
}
/** Trimmed value, or the fallback when absent/blank. */
public static String strOr(final JsonObject obj, final String key, final String fallback) {
final String value = str(obj, key);
return value == null || value.isBlank() ? fallback : value.trim();
}
/**
* True when a field holds real content. Treats the {@code '—'} em-dash as empty: the workflow uses
* it as a "nothing recorded yet" placeholder throughout, so a check for "has this been filled in"
* has to reject it or every blank field would read as populated.
*/
public static boolean filled(final JsonObject obj, final String key) {
final String value = str(obj, key);
return value != null && !value.trim().isEmpty() && !"—".equals(value.trim());
}
public static Long lng(final JsonObject obj, final String key) {
try {
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
} catch (final NumberFormatException e) {
return null;
}
}
public static BigDecimal dec(final JsonObject obj, final String key) {
try {
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsBigDecimal() : null;
} catch (final NumberFormatException e) {
return null;
}
}
public static boolean bool(final JsonObject obj, final String key) {
return present(obj, key) && obj.get(key).isJsonPrimitive() && obj.get(key).getAsBoolean();
}
public static int intOr(final JsonObject obj, final String key, final int fallback) {
try {
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsInt() : fallback;
} catch (final NumberFormatException e) {
return fallback;
}
}
public static JsonObject obj(final JsonObject parent, final String key) {
return present(parent, key) && parent.get(key).isJsonObject() ? parent.getAsJsonObject(key) : null;
}
/** The nested object, creating and attaching an empty one if it isn't there yet. */
public static JsonObject objOrCreate(final JsonObject parent, final String key) {
final JsonObject existing = obj(parent, key);
if (existing != null) {
return existing;
}
final JsonObject created = new JsonObject();
parent.add(key, created);
return created;
}
public static JsonArray arr(final JsonObject parent, final String key) {
return present(parent, key) && parent.get(key).isJsonArray() ? parent.getAsJsonArray(key) : new JsonArray();
}
public static JsonArray arrOrCreate(final JsonObject parent, final String key) {
if (present(parent, key) && parent.get(key).isJsonArray()) {
return parent.getAsJsonArray(key);
}
final JsonArray created = new JsonArray();
parent.add(key, created);
return created;
}
public static String rawJson(final JsonObject obj, final String key) {
return present(obj, key) ? obj.get(key).toString() : null;
}
}
@@ -0,0 +1,403 @@
/**
* 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.loanoriginationpipeline.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Persistence for the Loan Origination Pipeline — the pre-disbursement sanctioning workflow
* (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit Committee →
* Risk → Legal → Signing → Cheque/ACH → Post-Check → Disbursed).
*
* <p>
* <b>Storage shape.</b> {@code payload} holds the complete application object as JSON and is the
* single source of truth for reads. The dozen real columns on
* {@code m_loan_origination_application}, and the four child tables, are <i>derived indexes</i> —
* they exist so the Pipeline list, the Channel/BC/Share reports, Target-vs-Actual and audit queries
* ("every application this officer approved") can be plain SQL instead of scanning JSON. They are
* never read back into the application object.
*
* <p>
* That deliberately stores those fields twice, which is the right trade here: every write goes
* through {@link #writeApplication} in one transaction and rewrites the payload, the columns and all
* child rows together from the same source, so the copies cannot drift. The alternative — splitting
* fields so nothing repeats — would force the read path to reconstruct a dozen values back out of
* SQL types (a {@code DECIMAL(19,6)} returning {@code 100000.000000} where the UI expects
* {@code 100000}, a null {@code BIGINT} vs an absent key), which is exactly the class of silent
* field-level corruption this module cannot afford.
*
* <p>
* The one field kept out of {@code payload} is {@code cicParsedAnalysis} — a parsed credit-bureau
* report with a per-account seven-year day-past-due grid. It is large, opaque and only ever rendered
* whole, so it gets its own column rather than being duplicated alongside everything else.
*
* <p>
* Because the payload is "the whole object minus that one field" rather than an allowlist of known
* keys, a field added to the workflow later persists with no schema or code change here. Given how
* fast this workflow gains fields, an allowlist would drop them silently.
*/
@Service
@RequiredArgsConstructor
public class LoanOriginationPipelineService {
/** Pulled out of the payload into its own column — see class javadoc. */
private static final String CIC_ANALYSIS_KEY = "cicParsedAnalysis";
/**
* The row's primary key. Kept out of the payload so it can only ever come from the column — a
* stale id round-tripped inside the JSON would silently point uploaded documents at the wrong
* application.
*/
private static final String ID_KEY = "id";
private static final String SELECT_COLUMNS = "id, payload, cic_parsed_analysis";
private final JdbcTemplate jdbcTemplate;
// ── Read ─────────────────────────────────────────────────────────────────
/**
* Every application, newest first. Optional filters are pushed into SQL; the Pipeline screen also
* filters in-memory, so passing nothing here is perfectly valid.
*/
public String findAllAsJson(final String branchCode, final String stage, final String status) {
final StringBuilder sql = new StringBuilder("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE 1=1");
final List<Object> params = new ArrayList<>();
if (isSet(branchCode)) {
sql.append(" AND branch_code = ?");
params.add(branchCode);
}
if (isSet(stage)) {
sql.append(" AND current_stage = ?");
params.add(stage);
}
if (isSet(status)) {
sql.append(" AND overall_status = ?");
params.add(status);
}
sql.append(" ORDER BY id DESC");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
final JsonArray out = new JsonArray();
for (final Map<String, Object> row : rows) {
out.add(reassemble(row));
}
return out.toString();
}
public String findOneAsJson(final String applicationNo) {
return loadApplication(applicationNo).toString();
}
/**
* Loads one application as a mutable object. This is the entry point the per-stage endpoints use:
* they validate and apply their transition against the state that is actually stored, never
* against what a caller claims it is.
*/
public JsonObject loadApplication(final String applicationNo) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE application_no = ?",
applicationNo);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found",
"No loan origination application found with number " + applicationNo + ".", applicationNo);
}
return reassemble(rows.get(0));
}
/**
* Rebuilds the application object: the stored payload, with the bureau report re-attached and the
* row's own id surfaced. The id is what uploaded files hang off — documents are stored against
* {@code m_document.parent_entity_id}, so the client needs it to upload and fetch attachments.
*/
private JsonObject reassemble(final Map<String, Object> row) {
final JsonObject app = parseObject(asString(row.get("payload")));
final Object id = row.get("id");
if (id != null) {
app.addProperty("id", Long.valueOf(id.toString()));
}
final String cic = asString(row.get("cic_parsed_analysis"));
if (cic != null && !cic.isBlank()) {
app.add(CIC_ANALYSIS_KEY, JsonParser.parseString(cic));
}
return app;
}
// ── Write ────────────────────────────────────────────────────────────────
/**
* Creates an application. The caller (the New Application wizard) has already derived the
* application number and the blank per-stage structures, so this stores the object as given
* rather than re-deriving a second, competing set of defaults server-side.
*/
@Transactional
public String create(final String requestBodyAsJson) {
final JsonObject app = parseObject(requestBodyAsJson);
final String applicationNo = stringOrNull(app, "applicationNo");
if (applicationNo == null || applicationNo.isBlank()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.no.required",
"applicationNo is required.");
}
final Long existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_loan_origination_application WHERE application_no = ?", Long.class, applicationNo);
if (existing != null && existing > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.duplicate",
"An application numbered " + applicationNo + " already exists.", applicationNo);
}
// Read back rather than echoing the request: the caller needs the assigned id to be able to
// attach documents to this application.
insertApplication(app);
return findOneAsJson(applicationNo);
}
/**
* Full-object save. The workflow's stage transitions are all applied client-side against the
* whole application object, so the whole object is what gets written back — there is no
* per-action field allowlist that could quietly omit a field.
*/
@Transactional
public String save(final String applicationNo, final String requestBodyAsJson) {
return storeApplication(applicationNo, parseObject(requestBodyAsJson));
}
/** Writes a whole application object back. Shared by the full-object save and every per-stage
* endpoint, so both paths persist through exactly the same code. */
@Transactional
public String storeApplication(final String applicationNo, final JsonObject app) {
final Long id = findIdOrThrow(applicationNo);
final JsonObject payload = app.deepCopy();
payload.remove(CIC_ANALYSIS_KEY);
payload.remove(ID_KEY);
jdbcTemplate.update("""
UPDATE m_loan_origination_application SET
applicant_name = ?, client_id = ?, branch_code = ?, channel = ?, bc_agent_name = ?,
product_code = ?, requested_amount = ?, current_stage = ?, overall_status = ?,
is_share_member = ?, fineract_loan_id = ?, payload = ?, cic_parsed_analysis = ?,
last_modified_date = CURRENT_TIMESTAMP
WHERE id = ?
""", stringOrNull(app, "applicantName"), longOrNull(app, "clientId"), stringOrNull(app, "branchCode"),
stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"), stringOrNull(app, "productCode"),
decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"), stringOrNull(app, "overallStatus"),
boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"), payload.toString(),
childJsonOrNull(app, CIC_ANALYSIS_KEY), id);
writeChildren(id, app);
return findOneAsJson(applicationNo);
}
/**
* One-time seed. Idempotent by design: it is called on first page load and must not duplicate the
* book if the frontend is reloaded, so a non-empty table short-circuits to 0.
*/
@Transactional
public int seed(final String requestBodyAsJson) {
final Long existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_loan_origination_application", Long.class);
if (existing != null && existing > 0) {
return 0;
}
final JsonElement parsed = JsonParser.parseString(requestBodyAsJson == null ? "[]" : requestBodyAsJson);
if (!parsed.isJsonArray()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.seed.invalid",
"Seed body must be a JSON array of applications.");
}
int seeded = 0;
for (final JsonElement element : parsed.getAsJsonArray()) {
if (element.isJsonObject()) {
insertApplication(element.getAsJsonObject());
seeded++;
}
}
return seeded;
}
private void insertApplication(final JsonObject app) {
final JsonObject payload = app.deepCopy();
payload.remove(CIC_ANALYSIS_KEY);
payload.remove(ID_KEY);
jdbcTemplate.update("""
INSERT INTO m_loan_origination_application
(application_no, applicant_name, client_id, branch_code, channel, bc_agent_name,
product_code, requested_amount, current_stage, overall_status, is_share_member,
fineract_loan_id, payload, cic_parsed_analysis)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", stringOrNull(app, "applicationNo"), stringOrNull(app, "applicantName"), longOrNull(app, "clientId"),
stringOrNull(app, "branchCode"), stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"),
stringOrNull(app, "productCode"), decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"),
stringOrNull(app, "overallStatus"), boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"),
payload.toString(), childJsonOrNull(app, CIC_ANALYSIS_KEY));
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_loan_origination_application WHERE application_no = ?",
Long.class, stringOrNull(app, "applicationNo"));
writeChildren(id, app);
}
/**
* Rewrites the derived child rows for one application. Delete-then-insert rather than a per-row
* diff: these collections are small (a stage history is a dozen rows), and a full rewrite cannot
* leave a stale row behind the way a partial diff can.
*/
private void writeChildren(final Long id, final JsonObject app) {
jdbcTemplate.update("DELETE FROM m_loan_origination_stage_history WHERE application_id = ?", id);
jdbcTemplate.update("DELETE FROM m_loan_origination_approval WHERE application_id = ?", id);
jdbcTemplate.update("DELETE FROM m_loan_origination_fi_check WHERE application_id = ?", id);
jdbcTemplate.update("DELETE FROM m_loan_origination_document WHERE application_id = ?", id);
final JsonArray history = arrayOrEmpty(app, "stageHistory");
for (int i = 0; i < history.size(); i++) {
if (!history.get(i).isJsonObject()) {
continue;
}
final JsonObject entry = history.get(i).getAsJsonObject();
jdbcTemplate.update("""
INSERT INTO m_loan_origination_stage_history
(application_id, seq_no, stage, action_date, action_by, outcome, remarks)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", id, i, stringOrNull(entry, "stage"), stringOrNull(entry, "date"), stringOrNull(entry, "actionBy"),
stringOrNull(entry, "outcome"), stringOrNull(entry, "remarks"));
}
for (final String field : new String[] { "creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval" }) {
final JsonObject approval = objectOrNull(app, field);
if (approval == null) {
continue;
}
jdbcTemplate.update("""
INSERT INTO m_loan_origination_approval
(application_id, approval_field, decision, approver_name, approver_role, decision_date, remarks)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", id, field, stringOrNull(approval, "decision"), stringOrNull(approval, "approverName"),
stringOrNull(approval, "approverRole"), stringOrNull(approval, "date"), stringOrNull(approval, "remarks"));
}
for (final String key : new String[] { "addressVerification", "officeVerification", "propertyVerification",
"shopVerification" }) {
final JsonObject check = objectOrNull(app, key);
if (check == null) {
continue;
}
jdbcTemplate.update("""
INSERT INTO m_loan_origination_fi_check
(application_id, check_key, status, verified_by, verified_date, remarks, officer_assigned,
assigned_date, assigned_at, latitude, longitude, photo_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", id, key, stringOrNull(check, "status"), stringOrNull(check, "verifiedBy"),
stringOrNull(check, "verifiedDate"), stringOrNull(check, "remarks"), stringOrNull(check, "officerAssigned"),
stringOrNull(check, "assignedDate"), longOrNull(check, "assignedAt"), stringOrNull(check, "latitude"),
stringOrNull(check, "longitude"), intOrZero(check, "photoCount"));
}
insertDocuments(id, arrayOrEmpty(app, "productDocuments"), "PRODUCT");
insertDocuments(id, arrayOrEmpty(app, "legalDocuments"), "LEGAL");
}
private void insertDocuments(final Long id, final JsonArray docs, final String kind) {
for (int i = 0; i < docs.size(); i++) {
if (!docs.get(i).isJsonObject()) {
continue;
}
final JsonObject doc = docs.get(i).getAsJsonObject();
jdbcTemplate.update("""
INSERT INTO m_loan_origination_document
(application_id, doc_kind, doc_key, label, hint, is_custom, submitted, seq_no)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", id, kind, stringOrNull(doc, "key"), stringOrNull(doc, "label"), stringOrNull(doc, "hint"),
boolOrFalse(doc, "custom"), boolOrFalse(doc, "submitted"), i);
}
}
private Long findIdOrThrow(final String applicationNo) {
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_loan_origination_application WHERE application_no = ?",
Long.class, applicationNo);
if (ids.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found",
"No loan origination application found with number " + applicationNo + ".", applicationNo);
}
return ids.get(0);
}
// ── JSON helpers ─────────────────────────────────────────────────────────
// Thin aliases over LoanOriginationJson so this class and the per-stage service share one
// implementation of the null/absent/wrong-type handling rather than each keeping its own.
private JsonObject parseObject(final String body) {
if (body == null || body.isBlank()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.required", "A request body is required.");
}
return LoanOriginationJson.parseObject(body);
}
private boolean present(final JsonObject obj, final String key) {
return LoanOriginationJson.present(obj, key);
}
private String stringOrNull(final JsonObject obj, final String key) {
return LoanOriginationJson.str(obj, key);
}
private Long longOrNull(final JsonObject obj, final String key) {
return LoanOriginationJson.lng(obj, key);
}
private BigDecimal decimalOrNull(final JsonObject obj, final String key) {
return LoanOriginationJson.dec(obj, key);
}
private boolean boolOrFalse(final JsonObject obj, final String key) {
return LoanOriginationJson.bool(obj, key);
}
private int intOrZero(final JsonObject obj, final String key) {
return LoanOriginationJson.intOr(obj, key, 0);
}
private JsonObject objectOrNull(final JsonObject obj, final String key) {
return LoanOriginationJson.obj(obj, key);
}
private JsonArray arrayOrEmpty(final JsonObject obj, final String key) {
return LoanOriginationJson.arr(obj, key);
}
private String childJsonOrNull(final JsonObject obj, final String key) {
return LoanOriginationJson.rawJson(obj, key);
}
private String asString(final Object value) {
return value == null ? null : value.toString();
}
private boolean isSet(final String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,517 @@
/**
* 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.loanoriginationpipeline.service;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arr;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arrOrCreate;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.bool;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.filled;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.intOr;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.obj;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.objOrCreate;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.parseObject;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.str;
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.strOr;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Server-side stage transitions for the Loan Origination Pipeline.
*
* <p>
* Each method here owns one sanctioning step: it loads the application <b>as stored</b>, checks that
* step's preconditions against that stored state, applies the change, appends the audit-trail entry
* and saves. The caller supplies only that step's own inputs (who acted, remarks, a resolution
* number) — never the resulting state — so a client cannot approve a stage whose prerequisites are
* not actually met, mark a field visit verified without the GPS fix and photographs really being on
* file, or minute a Credit Committee decision without a resolution number.
*
* <p>
* That distinction matters for a sanctioning workflow: the screen also disables those buttons, but UI
* gating is a convenience, not a control — it is bypassed by any direct call to the API. These checks
* are the control. They are deliberately the same rules the screen enforces, so an officer never sees
* an enabled button that the server then rejects.
*
* <p>
* Dates are written in the {@code dd-MM-yyyy} form the rest of the workflow record already uses, and
* "nothing recorded" stays the {@code '—'} placeholder the existing data uses, so a server-applied
* transition is indistinguishable in shape from the client-applied ones already stored.
*/
@Service
@RequiredArgsConstructor
public class LoanOriginationStageService {
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
/** Must stay in step with STAGE_ORDER on the Application Detail screen. */
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION",
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
"POST_CHECK", "DISBURSED");
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
"shopVerification");
private final LoanOriginationPipelineService pipelineService;
// ── KYC & Documents ──────────────────────────────────────────────────────
@Transactional
public String completeKyc(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "KYC_DOCUMENTS");
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
require(bool(app, "panVerified"), "PAN has not been verified.");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
for (final JsonElement element : arr(app, "productDocuments")) {
final JsonObject doc = element.getAsJsonObject();
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
}
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
return pipelineService.storeApplication(applicationNo, app);
}
// ── CIC / Credit Bureau ──────────────────────────────────────────────────
@Transactional
public String completeCicCheck(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "CIC_CHECK");
// RBI's Fair Practices Code requires the borrower's written consent before a bureau pull, and
// the bureau's per-pull fee has to have actually been collected.
require(bool(app, "cicConsentSigned"), "The borrower's signed credit-bureau consent is not on file.");
require(bool(app, "cicChargeCollected"), "The credit bureau check charge has not been collected.");
require(bool(app, "cicReportUploaded"), "No credit bureau report has been uploaded.");
advance(app, actor(input), "CIC Check Complete",
strOr(input, "remarks", "Credit bureau report obtained and reviewed. Score " + intOr(app, "cibilScore", 0) + "."));
return pipelineService.storeApplication(applicationNo, app);
}
// ── Field Investigation ──────────────────────────────────────────────────
/**
* Marks one field-investigation check verified. The GPS fix and site photographs are checked
* against what is stored, which is the point: "I visited" with nothing recorded is exactly what a
* field-investigation control exists to prevent.
*/
@Transactional
public String verifyFieldCheck(final String applicationNo, final String checkKey, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "FIELD_INVESTIGATION");
require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey);
final JsonObject check = objOrCreate(app, checkKey);
require(filled(check, "officerAssigned"), "No field officer has been assigned to this visit.");
require(filled(check, "latitude") && filled(check, "longitude"), "The visit has no GPS coordinates recorded.");
require(intOr(check, "photoCount", 0) > 0, "No site photograph has been captured for this visit.");
check.addProperty("status", "VERIFIED");
check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer")));
check.addProperty("verifiedDate", today());
check.addProperty("remarks", strOr(input, "remarks", "Visited and confirmed correct."));
return pipelineService.storeApplication(applicationNo, app);
}
@Transactional
public String rejectFieldCheck(final String applicationNo, final String checkKey, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "FIELD_INVESTIGATION");
require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey);
// A failed visit must say why -- it is what the Personal Discussion stage then works from.
final String remarks = strOr(input, "remarks", "");
require(!remarks.isBlank(), "A reason is required when a field visit fails.");
final JsonObject check = objOrCreate(app, checkKey);
check.addProperty("status", "FAILED");
check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer")));
check.addProperty("verifiedDate", today());
check.addProperty("remarks", remarks);
return pipelineService.storeApplication(applicationNo, app);
}
@Transactional
public String completeFieldInvestigation(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "FIELD_INVESTIGATION");
requireFieldInvestigationComplete(app);
advance(app, actor(input), "FI Complete", strOr(input, "remarks", "Field investigation complete on all applicable checks."));
return pipelineService.storeApplication(applicationNo, app);
}
// ── Personal Discussion ──────────────────────────────────────────────────
@Transactional
public String clearPersonalDiscussion(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "PERSONAL_DISCUSSION");
require(bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee.");
app.addProperty("discrepancyType", "NONE");
app.addProperty("overallStatus", "IN_PROGRESS");
app.addProperty("personalDiscussionDate", today());
app.addProperty("personalDiscussionNotes", remarks);
app.addProperty("declarationTaken", true);
app.addProperty("declarationDate", today());
advance(app, actor(input), "Cleared", remarks);
return pipelineService.storeApplication(applicationNo, app);
}
@Transactional
public String flagDiscrepancy(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "PERSONAL_DISCUSSION");
final String type = strOr(input, "discrepancyType", "");
require(!type.isBlank() && !"NONE".equals(type), "A discrepancy type is required.");
final String notes = strOr(input, "remarks", "");
require(!notes.isBlank(), "Notes are required when flagging a discrepancy.");
app.addProperty("discrepancyType", type);
app.addProperty("personalDiscussionDate", today());
app.addProperty("personalDiscussionNotes", notes);
// Held rather than rejected: a discrepancy is resolvable, and the application has to stay
// visible as outstanding work rather than dropping out of the pipeline.
app.addProperty("overallStatus", "ON_HOLD");
appendHistory(app, str(app, "currentStage"), actor(input), "Discrepancy Flagged", notes);
return pipelineService.storeApplication(applicationNo, app);
}
@Transactional
public String resolveDiscrepancy(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "PERSONAL_DISCUSSION");
require("ON_HOLD".equals(str(app, "overallStatus")), "This application is not on hold.");
final String remarks = strOr(input, "remarks", "Discrepancy resolved on further review — cleared to proceed.");
app.addProperty("discrepancyType", "NONE");
app.addProperty("overallStatus", "IN_PROGRESS");
app.addProperty("personalDiscussionNotes", remarks);
app.addProperty("declarationTaken", true);
app.addProperty("declarationDate", today());
advance(app, actor(input), "Resolved", remarks);
return pipelineService.storeApplication(applicationNo, app);
}
// ── Approvals ────────────────────────────────────────────────────────────
/**
* Records a Credit Committee / Risk / Legal / Post-Check decision.
*
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
*/
@Transactional
public String decideApproval(final String applicationNo, final String field, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
final String decision = strOr(input, "decision", "APPROVED");
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision);
final String expectedStage = switch (field) {
case "creditCommitteeApproval" -> "CREDIT_COMMITTEE";
case "riskApproval" -> "RISK_APPROVAL";
case "legalApproval" -> "LEGAL_APPROVAL";
case "postCheckApproval" -> "POST_CHECK";
default -> throw badRequest("Unknown approval stage: " + field);
};
requireStage(app, expectedStage);
// Prerequisites are only enforced for a positive decision: a rejection has to remain possible
// at any point, and blocking it on an incomplete checklist would trap the application.
if (!"REJECTED".equals(decision)) {
switch (field) {
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
case "riskApproval" -> requireRiskChecklistReady(app);
case "legalApproval" -> requireLegalChecklistDone(app);
case "postCheckApproval" -> requirePostCheckSignatures(app);
default -> throw badRequest("Unknown approval stage: " + field);
}
}
String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.");
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) {
// The resolution number and date are what the sanction letter later quotes, so they are
// recorded on the application and restated in the audit trail.
final String resolutionNo = strOr(input, "resolutionNo", "");
final String resolutionDate = strOr(input, "resolutionDate", "");
app.addProperty("committeeResolutionNo", resolutionNo);
app.addProperty("committeeResolutionDate", resolutionDate);
remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")";
}
final JsonObject approval = objOrCreate(app, field);
approval.addProperty("decision", decision);
approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff"));
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
approval.addProperty("date", today());
approval.addProperty("remarks", remarks);
if ("REJECTED".equals(decision)) {
app.addProperty("overallStatus", "REJECTED");
appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks);
} else {
advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
}
return pipelineService.storeApplication(applicationNo, app);
}
// ── Signing / Cheque-ACH / Disbursement ──────────────────────────────────
@Transactional
public String markSigned(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "CUSTOMER_SIGNING");
require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded.");
app.addProperty("customerSigningDate", today());
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
strOr(input, "remarks", "Loan agreement signed by the customer."));
return pipelineService.storeApplication(applicationNo, app);
}
@Transactional
public String confirmChequeAch(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "CHEQUE_ACH");
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
advance(app, actor(input), "Cheque & ACH Received",
strOr(input, "remarks", "Security cheques received and ACH mandate signed."));
return pipelineService.storeApplication(applicationNo, app);
}
/**
* Final disbursement. Requires every approval level to actually stand as approved on the stored
* record — the last point at which an application could otherwise slip through with a rejected or
* still-pending sign-off.
*/
@Transactional
public String disburse(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "DISBURSED");
require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed.");
for (final String field : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval")) {
final JsonObject approval = obj(app, field);
final String decision = approval == null ? null : str(approval, "decision");
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision),
"Cannot disburse: " + field + " is not approved.");
}
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
final String mode = strOr(input, "disbursementMode", "Account Credit");
app.addProperty("disbursementDate", today());
app.addProperty("disbursementMode", mode);
if (!filled(app, "achConfirmationRef")) {
final String suffix = applicationNo.length() >= 6 ? applicationNo.substring(applicationNo.length() - 6) : applicationNo;
app.addProperty("achConfirmationRef", "ACH" + suffix);
}
app.addProperty("emiActivationConfirmed", true);
app.addProperty("emiActivationDate", today());
app.addProperty("overallStatus", "DISBURSED");
if (LoanOriginationJson.present(input, "fineractLoanId")) {
app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId"));
app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo"));
}
appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks",
"Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed."));
return pipelineService.storeApplication(applicationNo, app);
}
// ── Shared checklist rules (mirror the Application Detail screen's gating) ─
private void requireFieldInvestigationComplete(final JsonObject app) {
requireCheckVerified(app, "addressVerification", "Address verification");
requireCheckVerified(app, "officeVerification", "Office/employment verification");
if (bool(app, "secured")) {
requireCheckVerified(app, "propertyVerification", "Property verification");
}
if (bool(app, "hasShop")) {
requireCheckVerified(app, "shopVerification", "Shop verification");
}
}
private void requireCheckVerified(final JsonObject app, final String key, final String label) {
final JsonObject check = obj(app, key);
require(check != null && "VERIFIED".equals(str(check, "status")), label + " is not verified.");
}
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
if (bool(app, "secured")) {
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
}
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
require(bool(app, "panVerified"), "PAN has not been verified.");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
requireFieldInvestigationComplete(app);
require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
}
private void requireRiskChecklistReady(final JsonObject app) {
requireCheckVerified(app, "addressVerification", "Address verification");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
require(bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
if (bool(app, "secured")) {
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
}
requireFieldInvestigationComplete(app);
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
if (bool(app, "guarantorRequired")) {
// A guarantor at a co-operative bank has to be an existing member, not just a name: an
// onboarded client id AND a live share account.
require(LoanOriginationJson.lng(app, "guarantorClientId") != null,
"A guarantor is required but no existing client has been selected.");
require(bool(app, "guarantorHasShareAccount"), "The selected guarantor has no active share account.");
}
}
private void requireLegalChecklistDone(final JsonObject app) {
require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete.");
if (bool(app, "secured")) {
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
}
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
require(bool(app, "fileFilingDone"), "File filing is not complete.");
for (final JsonElement element : arr(app, "legalDocuments")) {
final JsonObject doc = element.getAsJsonObject();
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
}
}
private void requirePostCheckSignatures(final JsonObject app) {
require(bool(app, "signedByAgent"), "The agent has not signed off.");
require(bool(app, "signedByManager"), "The manager has not signed off.");
require(bool(app, "signedByCommittee"), "The committee has not signed off.");
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");
}
// ── Stage / history mechanics ────────────────────────────────────────────
private void requireStage(final JsonObject app, final String expected) {
final String actual = str(app, "currentStage");
if (!expected.equals(actual)) {
throw badRequest("This action applies at the " + expected + " stage, but the application is at " + actual + ".");
}
final String status = str(app, "overallStatus");
if ("REJECTED".equals(status)) {
throw badRequest("This application has been rejected and cannot be progressed.");
}
}
/**
* Moves the application to the next stage and appends the audit entry. {@code furthestStageIndex}
* only ever increases, so jumping back to an earlier stage for a correction does not make
* already-completed stages look unfinished.
*/
private void advance(final JsonObject app, final String actionBy, final String outcome, final String remarks) {
final String current = str(app, "currentStage");
appendHistory(app, current, actionBy, outcome, remarks);
final int index = STAGE_ORDER.indexOf(current);
if (index >= 0 && index < STAGE_ORDER.size() - 1) {
final int next = index + 1;
app.addProperty("currentStage", STAGE_ORDER.get(next));
app.addProperty("furthestStageIndex", Math.max(intOr(app, "furthestStageIndex", 0), next));
}
}
private void appendHistory(final JsonObject app, final String stage, final String actionBy, final String outcome,
final String remarks) {
final JsonArray history = arrOrCreate(app, "stageHistory");
final JsonObject entry = new JsonObject();
entry.addProperty("stage", stage);
entry.addProperty("date", today());
entry.addProperty("actionBy", actionBy);
entry.addProperty("outcome", outcome);
entry.addProperty("remarks", remarks);
history.add(entry);
}
private String actor(final JsonObject input) {
return strOr(input, "actionBy", "Bank Staff");
}
private String today() {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
return (businessDate == null ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) : businessDate).format(WORKFLOW_DATE);
}
private void require(final boolean condition, final String message) {
if (!condition) {
throw badRequest(message);
}
}
private GeneralPlatformDomainRuleException badRequest(final String message) {
return new GeneralPlatformDomainRuleException("error.msg.loan.origination.stage.invalid", message);
}
/** Kept for symmetry with the check-key list above; used by callers validating a key up front. */
public static boolean isKnownFieldCheck(final String checkKey) {
return FI_CHECK_KEYS.contains(checkKey);
}
/** Exposed so the API layer can advertise the stage order without duplicating it. */
public static List<String> stageOrder() {
return Arrays.asList(STAGE_ORDER.toArray(new String[0]));
}
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.mobilelookup.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@@ -26,11 +28,16 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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.core.MediaType;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -63,6 +70,8 @@ public class MobileLookupApiResource {
private final MobileLookupService mobileLookupService;
private final DefaultToApiJsonSerializer<MobileLookupResult> resultSerializer;
private final DefaultToApiJsonSerializer<List> listSerializer;
private final DefaultToApiJsonSerializer<MobileLookupLogData> logSerializer;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
@GET
@Path("/check")
@@ -80,6 +89,20 @@ public class MobileLookupApiResource {
@QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) {
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
final MobileLookupResult result = mobileLookupService.checkMobileNumber(mobileNumber);
// Surface any earlier rejection of the same number so the officer screening it today is
// not the only one without that history.
final MobileLookupLog priorRejection = mobileLookupService.findPriorRejection(mobileNumber);
if (priorRejection != null && !priorRejection.getId().equals(result.getLookupLogId())) {
final Map<String, Object> payload = new LinkedHashMap<>();
payload.put("lookupLogId", result.getLookupLogId());
payload.put("success", result.isSuccess());
payload.put("message", result.getMessage());
payload.put("data", result.getData());
payload.put("timestamp", result.getTimestamp());
payload.put("priorRejection", MobileLookupLogData.fromEntity(priorRejection));
return mapSerializer.serialize(payload);
}
return resultSerializer.serialize(result);
}
@@ -107,10 +130,77 @@ public class MobileLookupApiResource {
logs = mobileLookupService.getLookupLogs();
}
final List<MobileLookupLogData> logDataList = logs.stream()
.map(MobileLookupLogData::fromEntity)
.collect(Collectors.toList());
return listSerializer.serialize(withClientNames(logs));
}
return listSerializer.serialize(logDataList);
/**
* Attaches the client's display name and account number to each row. The log stores only the
* client id, and a queue of bare ids is not something a reviewing officer can act on.
*/
private List<MobileLookupLogData> withClientNames(final List<MobileLookupLog> logs) {
final Set<Long> clientIds = logs.stream()
.map(MobileLookupLog::getClientId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
final Map<Long, String[]> names = mobileLookupService.resolveClientNames(clientIds);
return logs.stream().map(log -> {
final String[] client = log.getClientId() == null ? null : names.get(log.getClientId());
return MobileLookupLogData.fromEntity(log, client == null ? null : client[0],
client == null ? null : client[1]);
}).collect(Collectors.toList());
}
@GET
@Path("/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Lookups awaiting an officer's decision",
description = "SUSPECTED or DISCONNECTED numbers on which no PROCEEDED/REJECTED decision "
+ "has been recorded yet.")
public String getPendingDecisions() {
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
return listSerializer.serialize(withClientNames(mobileLookupService.getPendingDecisions()));
}
@POST
@Path("/logs/{logId}/decision")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record the officer's decision on a flagged number",
description = "Body: {\"decision\": \"PROCEEDED|REJECTED\", \"reason\": \"...\"}. "
+ "A reason is mandatory when proceeding despite a DIP/MNRL hit.")
public String recordDecision(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("OVERRIDE_MOBILE_LOOKUP");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final String decision = json.has("decision") ? json.get("decision").getAsString() : null;
final String reason = json.has("reason") ? json.get("reason").getAsString() : null;
final MobileLookupLogData result = MobileLookupLogData.fromEntity(mobileLookupService
.recordDecision(logId, decision, reason, this.context.authenticatedUser().getId()));
return logSerializer.serialize(result);
}
@POST
@Path("/rescreen")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Re-screen existing clients' mobile numbers against DIP/MNRL",
description = "Screens every active client's mobile and alternate mobile numbers, opening a "
+ "decision row for each hit. Detects numbers reported for fraud or reassigned after "
+ "the customer was onboarded.")
public String rescreenExistingClients() {
this.context.authenticatedUser().validateHasPermissionTo("OVERRIDE_MOBILE_LOOKUP");
final int flagged = mobileLookupService.rescreenExistingClients();
final Map<String, Object> response = new LinkedHashMap<>();
response.put("message", "DIP/MNRL re-screening complete");
response.put("numbersFlagged", flagged);
// Reported so "0 flagged" can be told apart from "0 clients were even looked at" — the
// two mean very different things and the caller could not previously distinguish them.
response.put("clientsScanned", mobileLookupService.countScreenableClients());
return mapSerializer.serialize(response);
}
}
@@ -34,9 +34,26 @@ public class MobileLookupLogData {
private final String responseMessage;
private final String lookupSource;
private final Long clientId;
/**
* Resolved separately from the log row, which stores only the id. A queue showing a bare
* "145" gives the reviewing officer nothing to act on — they need to know who it is.
*/
private final String clientName;
private final String clientAccountNo;
private final String lookedUpOn;
private final String overrideDecision;
private final String overrideReason;
private final Long decidedBy;
private final String decidedOn;
/** Whether this lookup permits the number to be used on a client record. */
private final boolean usable;
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
return fromEntity(log, null, null);
}
public static MobileLookupLogData fromEntity(final MobileLookupLog log, final String clientName,
final String clientAccountNo) {
return new MobileLookupLogData(
log.getId(),
log.getMobileNumber(),
@@ -46,6 +63,13 @@ public class MobileLookupLogData {
log.getResponseMessage(),
log.getLookupSource(),
log.getClientId(),
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null);
clientName,
clientAccountNo,
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null,
log.getOverrideDecision(),
log.getOverrideReason(),
log.getDecidedBy(),
log.getDecidedOn() != null ? log.getDecidedOn().toString() : null,
log.isUsable());
}
}
@@ -33,4 +33,65 @@ public interface MobileLookupService {
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
List<MobileLookupLog> getLookupLogsByStatus(String status);
/**
* Records the officer's decision on a lookup that came back SUSPECTED or DISCONNECTED.
*
* @param decision PROCEEDED or REJECTED
* @param reason mandatory when proceeding — why the number is acceptable anyway
*/
MobileLookupLog recordDecision(Long logId, String decision, String reason, Long userId);
/** Non-clean lookups still awaiting a decision. */
List<MobileLookupLog> getPendingDecisions();
/**
* Whether the number's latest lookup permits it to be stored on a client — clean, or a hit
* the officer explicitly decided to proceed with. A DoT outage is not a pass.
*/
boolean isMobileNumberUsable(String mobileNumber);
/** Latest lookup status for the number, or null when it has never been checked. */
String getLatestStatus(String mobileNumber);
/**
* The most recent time this number was rejected, on any client, or null if never. A number
* turned away once should not be screened as if it had no history.
*/
MobileLookupLog findPriorRejection(String mobileNumber);
/**
* Re-screens every active client's mobile numbers against DIP/MNRL, opening a decision row
* for each hit.
*
* <p>
* Onboarding-time screening only ever sees a number as it is typed. A number reported for
* fraud, or disconnected and reassigned, after the customer was onboarded is otherwise never
* detected — and that is precisely the case where the bank's OTPs start reaching a stranger.
* </p>
*
* @return count of numbers that came back flagged
*/
int rescreenExistingClients();
/**
* Display name and account number for the given client ids, keyed by id. Resolved in one
* query so a queue of lookups does not issue a round trip per row.
*/
java.util.Map<Long, String[]> resolveClientNames(java.util.Collection<Long> clientIds);
/** How many clients the re-screen sweep would look at — lets "0 flagged" be interpreted. */
int countScreenableClients();
/**
* Marks every open alert on a number as resolved, recording why.
*
* <p>
* Called when the number is replaced on a client: the alert then refers to a number the bank
* no longer uses, and leaving it "Awaiting Decision" would misrepresent it as outstanding work.
* </p>
*
* @return how many alerts were closed
*/
int closeOpenAlerts(String mobileNumber, Long clientId, String reason);
}
@@ -21,8 +21,13 @@ package org.apache.fineract.portfolio.mobilelookup.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData;
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult;
import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog;
@@ -30,6 +35,8 @@ import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogReposito
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
@@ -50,15 +57,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
"9752123456", "9752345678", "9752987654"
);
/** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */
private static final Set<String> DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002");
/** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */
private static final Set<String> DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003");
private final String baseUrl;
private final boolean enabled;
private final RestTemplate restTemplate;
private final MobileLookupLogRepository logRepository;
private final JdbcTemplate jdbcTemplate;
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
MobileLookupLogRepository logRepository) {
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
this.baseUrl = baseUrl;
this.enabled = enabled;
this.restTemplate = new RestTemplate();
this.logRepository = logRepository;
this.jdbcTemplate = jdbcTemplate;
}
@Override
@@ -94,6 +112,39 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return MobileLookupResult.clean(logId, trimmed);
}
// Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API
if (isDemoDisconnectedNumber(trimmed)) {
LOG.info("Demo disconnected number detected: {}", trimmed);
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "DISCONNECTED", false, true,
"Number is DISCONNECTED — found in MNRL records", null, source, clientId);
return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail);
}
// Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API
if (isDemoSuspectedAndDisconnectedNumber(trimmed)) {
LOG.info("Demo suspected+disconnected number detected: {}", trimmed);
final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected(
"active", "Demo DIP suspected number", "DEMO");
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true,
"Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId);
return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail);
}
// Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external
// call entirely rather than fail with a connection error. Logged distinctly from a genuine
// CLEAN result so it can never be mistaken for one in an audit trail.
if (!enabled) {
LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed);
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false,
"DIP/MNRL lookup is disabled by configuration — the number was not actually checked.",
"{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId);
return MobileLookupResult.clean(logId, trimmed);
}
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
LOG.info("Calling DIP/MNRL lookup API: {}", url);
@@ -113,13 +164,194 @@ public class MobileLookupServiceImpl implements MobileLookupService {
parsed.getData(), parsed.getTimestamp());
} catch (Exception e) {
LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage());
final Long logId = saveLog(trimmed, "ERROR", false, false,
e.getMessage(), rawResponse, source, clientId);
// An unreachable DoT service means the number was NOT checked. It is logged under its
// own status rather than ERROR so the queue can distinguish "we could not verify" from
// "the lookup itself failed", and it is never presented to the officer as a pass.
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_SERVICE_UNAVAILABLE, false, false,
"DIP/MNRL lookup service is unavailable — the number could not be verified.",
rawResponse, source, clientId);
return MobileLookupResult.serviceUnavailable(logId, trimmed,
"DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.");
"DIP/MNRL lookup service is unavailable — the number could not be verified.");
}
}
@Override
@Transactional
public MobileLookupLog recordDecision(final Long logId, final String decision, final String reason,
final Long userId) {
final MobileLookupLog log = logRepository.findById(logId)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.not.found",
"Mobile lookup " + logId + " does not exist."));
final String normalised = decision == null ? "" : decision.trim().toUpperCase();
if (!MobileLookupLog.DECISION_PROCEEDED.equals(normalised)
&& !MobileLookupLog.DECISION_REJECTED.equals(normalised)) {
throw new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.decision.invalid",
"Decision must be PROCEEDED or REJECTED.");
}
// Proceeding past a DIP hit is the judgement call that has to survive an audit, so it
// cannot be recorded without the officer's reasoning. Rejecting needs no justification.
if (MobileLookupLog.DECISION_PROCEEDED.equals(normalised) && (reason == null || reason.trim().length() < 10)) {
throw new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.reason.required",
"Record why this number is acceptable despite the DIP/MNRL result (minimum 10 characters).");
}
log.recordDecision(normalised, reason == null ? null : reason.trim(), userId);
logRepository.saveAndFlush(log);
LOG.warn("Mobile lookup {} for {} decided as {}", logId, log.getMobileNumber(), normalised);
return log;
}
@Override
@Transactional(readOnly = true)
public List<MobileLookupLog> getPendingDecisions() {
return logRepository.findPendingDecisions();
}
@Override
@Transactional(readOnly = true)
public boolean isMobileNumberUsable(final String mobileNumber) {
if (mobileNumber == null || mobileNumber.isBlank()) {
return true;
}
final MobileLookupLog latest = findLatest(mobileNumber.trim());
// No lookup on record is handled by the caller (it decides whether a check is mandatory);
// here only an actual result can veto.
return latest == null || latest.isUsable();
}
@Override
@Transactional(readOnly = true)
public String getLatestStatus(final String mobileNumber) {
if (mobileNumber == null || mobileNumber.isBlank()) {
return null;
}
final MobileLookupLog latest = findLatest(mobileNumber.trim());
return latest == null ? null : latest.getLookupStatus();
}
/** Newest lookup row for a number, or null when it has never been checked. */
private MobileLookupLog findLatest(final String mobileNumber) {
final List<MobileLookupLog> rows = logRepository.findLatestByMobileNumber(mobileNumber, PageRequest.of(0, 1));
return rows.isEmpty() ? null : rows.get(0);
}
@Override
@Transactional
public int rescreenExistingClients() {
// Active clients only (status 300). A pending client is still inside the onboarding flow,
// where the number is screened as it is entered; this sweep exists for the customers that
// flow has already finished with. Closed/rejected/withdrawn have no live relationship.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, mobile_no, alternate_mobile_no FROM m_client WHERE status_enum = 300 "
+ "AND (mobile_no IS NOT NULL OR alternate_mobile_no IS NOT NULL)");
LOG.info("DIP/MNRL re-screening starting for {} client(s) with a number on file", rows.size());
int flagged = 0;
for (final Map<String, Object> row : rows) {
final Long clientId = ((Number) row.get("id")).longValue();
flagged += rescreenNumber((String) row.get("mobile_no"), clientId);
flagged += rescreenNumber((String) row.get("alternate_mobile_no"), clientId);
}
LOG.info("DIP/MNRL re-screening complete: {} number(s) flagged across {} clients", flagged, rows.size());
return flagged;
}
@Override
@Transactional(readOnly = true)
public int countScreenableClients() {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_client WHERE status_enum = 300 "
+ "AND (mobile_no IS NOT NULL OR alternate_mobile_no IS NOT NULL)",
Integer.class);
return count == null ? 0 : count;
}
/** Screens one number, tolerating per-number failure so one bad row cannot abort the sweep. */
private int rescreenNumber(final String mobileNumber, final Long clientId) {
if (mobileNumber == null || mobileNumber.isBlank()) {
return 0;
}
final String trimmed = mobileNumber.trim();
// Re-running the sweep must not stack another identical alert onto the queue. An open
// alert for this number and client is the same case still awaiting the same decision;
// duplicating it buries the real work under repeats of itself. A number already decided
// is likewise left alone until its value or decision changes.
final MobileLookupLog existing = findLatest(trimmed);
if (existing != null && existing.requiresDecision() && java.util.Objects.equals(existing.getClientId(), clientId)) {
LOG.debug("Skipping re-screen of {} for client {} — an open alert already exists", trimmed, clientId);
return 0;
}
try {
final MobileLookupResult result = checkMobileNumber(trimmed, "PERIODIC_RESCREEN", clientId);
final boolean hit = result.getData() != null
&& (result.getData().isSuspected() || result.getData().isDisconnected());
return hit ? 1 : 0;
} catch (Exception e) {
LOG.error("Re-screening failed for {} (client {}): {}", mobileNumber, clientId, e.getMessage());
return 0;
}
}
@Override
@Transactional(readOnly = true)
public Map<Long, String[]> resolveClientNames(final Collection<Long> clientIds) {
final Map<Long, String[]> byId = new HashMap<>();
if (clientIds == null || clientIds.isEmpty()) {
return byId;
}
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
clientIds.toArray());
for (final Map<String, Object> row : rows) {
final Long id = ((Number) row.get("id")).longValue();
byId.put(id, new String[] { (String) row.get("display_name"), (String) row.get("account_no") });
}
return byId;
}
@Override
@Transactional
public int closeOpenAlerts(final String mobileNumber, final Long clientId, final String reason) {
if (mobileNumber == null || mobileNumber.isBlank()) {
return 0;
}
final List<MobileLookupLog> open = logRepository.findByMobileNumber(mobileNumber.trim()).stream()
.filter(MobileLookupLog::requiresDecision)
.filter(log -> log.getOverrideDecision() == null)
.collect(Collectors.toList());
for (final MobileLookupLog log : open) {
// REJECTED is the correct terminal state: the bank turned this number away. It also
// keeps isMobileNumberUsable() false, so the number cannot quietly come back into use.
log.recordDecision(MobileLookupLog.DECISION_REJECTED, reason, null);
if (log.getClientId() == null && clientId != null) {
log.setClientId(clientId);
}
}
if (!open.isEmpty()) {
logRepository.saveAll(open);
logRepository.flush();
LOG.info("Closed {} open DIP alert(s) for replaced number {} on client {}", open.size(), mobileNumber, clientId);
}
return open.size();
}
@Override
@Transactional(readOnly = true)
public MobileLookupLog findPriorRejection(final String mobileNumber) {
if (mobileNumber == null || mobileNumber.isBlank()) {
return null;
}
final List<MobileLookupLog> rejections = logRepository.findRejections(mobileNumber.trim(), PageRequest.of(0, 1));
return rejections.isEmpty() ? null : rejections.get(0);
}
@Override
@Transactional(readOnly = true)
public List<MobileLookupLog> getLookupLogs() {
@@ -212,6 +444,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return false;
}
private boolean isDemoDisconnectedNumber(final String mobileNumber) {
if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) {
if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private String getStringOrNull(JsonObject json, String key) {
JsonElement el = json.get(key);
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
@@ -28,6 +28,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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.core.MediaType;
@@ -35,6 +36,7 @@ import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -77,6 +79,7 @@ public class SanctionsScreeningApiResource {
private final DefaultToApiJsonSerializer<SanctionsScreeningResult> screeningResultSerializer;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
private final DefaultToApiJsonSerializer<List> listSerializer;
private final DefaultToApiJsonSerializer<SanctionsScreeningLogData> logSerializer;
@GET
@Path("/screen")
@@ -184,10 +187,124 @@ public class SanctionsScreeningApiResource {
logs = sanctionsScreeningService.getScreeningLogs();
}
final List<SanctionsScreeningLogData> logDataList = logs.stream()
.map(SanctionsScreeningLogData::fromEntity)
.collect(Collectors.toList());
return listSerializer.serialize(withClientNames(logs));
}
return listSerializer.serialize(logDataList);
/**
* Attaches the client's display name and account number to each row. The log stores only the
* client id, and a queue of bare ids is not something a reviewing officer can act on.
*/
private List<SanctionsScreeningLogData> withClientNames(final List<SanctionsScreeningLog> logs) {
final Set<Long> clientIds = logs.stream().map(SanctionsScreeningLog::getClientId).filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
final Map<Long, String[]> names = sanctionsScreeningService.resolveClientNames(clientIds);
return logs.stream().map(log -> {
final String[] client = log.getClientId() == null ? null : names.get(log.getClientId());
return SanctionsScreeningLogData.fromEntity(log, client == null ? null : client[0],
client == null ? null : client[1]);
}).collect(Collectors.toList());
}
// ── Alert disposition ─────────────────────────────────────────────────────
// A flagged screening is a case, not a verdict. These endpoints close it out: the reviewing
// officer records whether it is the same person, the Principal Officer signs off, and a
// confirmed match carries the FIU-IND / nodal officer reporting references.
@GET
@Path("/alerts")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List sanctions alerts",
description = "Flagged screenings and their disposition state. Filter with "
+ "?status=PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH.")
public String getAlerts(
@QueryParam("status") @Parameter(description = "Filter by disposition") final String status) {
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
return listSerializer.serialize(withClientNames(sanctionsScreeningService.getAlerts(status)));
}
@POST
@Path("/alerts/{logId}/disposition")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record the review outcome of a sanctions alert",
description = "Body: {\"disposition\": \"FALSE_POSITIVE|CONFIRMED_MATCH\", \"reason\": \"...\"}. "
+ "A false positive does not unblock onboarding until it is approved.")
public String recordDisposition(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("DISPOSE_SANCTIONS");
final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final String disposition = json.has("disposition") ? json.get("disposition").getAsString() : null;
final String reason = json.has("reason") ? json.get("reason").getAsString() : null;
final SanctionsScreeningLogData result = SanctionsScreeningLogData
.fromEntity(sanctionsScreeningService.recordDisposition(logId, disposition, reason));
return logSerializer.serialize(result);
}
@POST
@Path("/alerts/{logId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Principal Officer approval of an alert disposition",
description = "Approving a FALSE_POSITIVE clears the name for onboarding. Approving a "
+ "CONFIRMED_MATCH freezes debits on every active deposit account of the client.")
public String approveAlert(@PathParam("logId") final Long logId) {
this.context.authenticatedUser().validateHasPermissionTo("APPROVE_SANCTIONS");
final SanctionsScreeningLogData result = SanctionsScreeningLogData
.fromEntity(sanctionsScreeningService.approveDisposition(logId));
return logSerializer.serialize(result);
}
@POST
@Path("/alerts/{logId}/report")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record FIU-IND / nodal officer reporting for a confirmed match",
description = "Body: {\"fiuStrReference\": \"...\", \"nodalOfficerInformedOn\": \"yyyy-MM-dd\"}")
public String recordReporting(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("APPROVE_SANCTIONS");
final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final String strReference = json.has("fiuStrReference") ? json.get("fiuStrReference").getAsString() : null;
final String informedOn = json.has("nodalOfficerInformedOn") ? json.get("nodalOfficerInformedOn").getAsString() : null;
final SanctionsScreeningLogData result = SanctionsScreeningLogData
.fromEntity(sanctionsScreeningService.recordReporting(logId, strReference, informedOn));
return logSerializer.serialize(result);
}
@GET
@Path("/str-cases")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Sanctions STR cases for the FIU-IND screen",
description = "Confirmed matches carrying a reporting obligation. Filter with "
+ "?status=PENDING_FILING | FILED. Kept separate from transaction-driven AML alerts.")
public String getStrCases(
@QueryParam("status") @Parameter(description = "PENDING_FILING or FILED") final String status) {
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
return listSerializer.serialize(withClientNames(sanctionsScreeningService.getStrCases(status)));
}
@POST
@Path("/rescreen")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Re-screen all existing clients against the current list snapshot",
description = "Opens a PENDING_REVIEW alert for every existing client that now matches. "
+ "This is what detects a customer added to the list after onboarding.")
public String rescreenExistingClients() {
this.context.authenticatedUser().validateHasPermissionTo("LOAD_SANCTIONS");
final int flagged = sanctionsScreeningService.rescreenExistingClients();
final Map<String, Object> response = new HashMap<>();
response.put("message", "Re-screening complete");
response.put("clientsFlagged", flagged);
return mapSerializer.serialize(response);
}
}
@@ -34,14 +34,38 @@ public final class SanctionsScreeningLogData {
private final String matchedNames;
private final String screeningSource;
private final Long clientId;
/**
* Resolved separately from the log row, which stores only the id. A queue showing a bare
* "124" gives the reviewing officer nothing to act on — they need to know who it is.
*/
private final String clientName;
private final String clientAccountNo;
private final String ipAddress;
private final OffsetDateTime screenedOn;
private final Long screenedBy;
private final String disposition;
private final String dispositionReason;
private final Long reviewedBy;
private final OffsetDateTime reviewedOn;
private final Long approvedBy;
private final OffsetDateTime approvedOn;
private final String fiuStrReference;
private final OffsetDateTime nodalOfficerInformedOn;
private final String frozenAccountIds;
private final OffsetDateTime frozenOn;
private final String strStatus;
private final String strDueDate;
private final String strRaisedOn;
public SanctionsScreeningLogData(final Long id, final String screenedName, final String screeningResult,
final Integer matchCount, final String matchedEntryIds, final String matchedNames,
final String screeningSource, final Long clientId, final String ipAddress,
final OffsetDateTime screenedOn, final Long screenedBy) {
final String screeningSource, final Long clientId, final String clientName, final String clientAccountNo,
final String ipAddress,
final OffsetDateTime screenedOn, final Long screenedBy, final String disposition,
final String dispositionReason, final Long reviewedBy, final OffsetDateTime reviewedOn,
final Long approvedBy, final OffsetDateTime approvedOn, final String fiuStrReference,
final OffsetDateTime nodalOfficerInformedOn, final String frozenAccountIds,
final OffsetDateTime frozenOn, final String strStatus, final String strDueDate, final String strRaisedOn) {
this.id = id;
this.screenedName = screenedName;
this.screeningResult = screeningResult;
@@ -50,12 +74,32 @@ public final class SanctionsScreeningLogData {
this.matchedNames = matchedNames;
this.screeningSource = screeningSource;
this.clientId = clientId;
this.clientName = clientName;
this.clientAccountNo = clientAccountNo;
this.ipAddress = ipAddress;
this.screenedOn = screenedOn;
this.screenedBy = screenedBy;
this.disposition = disposition;
this.dispositionReason = dispositionReason;
this.reviewedBy = reviewedBy;
this.reviewedOn = reviewedOn;
this.approvedBy = approvedBy;
this.approvedOn = approvedOn;
this.fiuStrReference = fiuStrReference;
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
this.frozenAccountIds = frozenAccountIds;
this.frozenOn = frozenOn;
this.strStatus = strStatus;
this.strDueDate = strDueDate;
this.strRaisedOn = strRaisedOn;
}
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
return fromEntity(log, null, null);
}
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log, final String clientName,
final String clientAccountNo) {
return new SanctionsScreeningLogData(
log.getId(),
log.getScreenedName(),
@@ -65,9 +109,24 @@ public final class SanctionsScreeningLogData {
log.getMatchedNames(),
log.getScreeningSource(),
log.getClientId(),
clientName,
clientAccountNo,
log.getIpAddress(),
log.getScreenedOn(),
log.getCreatedBy().orElse(null)
log.getCreatedBy().orElse(null),
log.getDisposition(),
log.getDispositionReason(),
log.getReviewedBy(),
log.getReviewedOn(),
log.getApprovedBy(),
log.getApprovedOn(),
log.getFiuStrReference(),
log.getNodalOfficerInformedOn(),
log.getFrozenAccountIds(),
log.getFrozenOn(),
log.getStrStatus(),
log.getStrDueDate() != null ? log.getStrDueDate().toString() : null,
log.getStrRaisedOn() != null ? log.getStrRaisedOn().toString() : null
);
}
@@ -79,7 +138,22 @@ public final class SanctionsScreeningLogData {
public String getMatchedNames() { return matchedNames; }
public String getScreeningSource() { return screeningSource; }
public Long getClientId() { return clientId; }
public String getClientName() { return clientName; }
public String getClientAccountNo() { return clientAccountNo; }
public String getIpAddress() { return ipAddress; }
public OffsetDateTime getScreenedOn() { return screenedOn; }
public Long getScreenedBy() { return screenedBy; }
public String getDisposition() { return disposition; }
public String getDispositionReason() { return dispositionReason; }
public Long getReviewedBy() { return reviewedBy; }
public OffsetDateTime getReviewedOn() { return reviewedOn; }
public Long getApprovedBy() { return approvedBy; }
public OffsetDateTime getApprovedOn() { return approvedOn; }
public String getFiuStrReference() { return fiuStrReference; }
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
public String getFrozenAccountIds() { return frozenAccountIds; }
public OffsetDateTime getFrozenOn() { return frozenOn; }
public String getStrStatus() { return strStatus; }
public String getStrDueDate() { return strDueDate; }
public String getStrRaisedOn() { return strRaisedOn; }
}
@@ -0,0 +1,117 @@
/**
* 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.sanctions.service;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Freezes and releases a client's deposit accounts when a UNSC sanctions match is confirmed.
*
* <p>
* Under Section 51-A UAPA 1967 a confirmed match means funds may not move OUT — the account is
* not closed and incoming credits are not refused, so this applies a debit block rather than a
* full account block. Freezing is only reachable for a client that already exists; a match at
* onboarding has no account to freeze and is handled by refusing to create the client at all.
* </p>
*/
@Service
@RequiredArgsConstructor
public class SanctionsFreezeService {
private static final Logger LOG = LoggerFactory.getLogger(SanctionsFreezeService.class);
private final SavingsAccountRepository savingsAccountRepository;
/**
* Blocks debits on every active savings account of the client.
*
* @return comma-separated ids of the accounts actually frozen (empty string when the client
* has no active account — a legitimate outcome, not an error)
*/
@Transactional
public String freezeClientAccounts(final Long clientId, final Long alertId) {
if (clientId == null) {
return "";
}
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
final List<String> frozen = new ArrayList<>();
for (final SavingsAccount account : accounts) {
// blockDebits() rejects anything not ACTIVE and anything already fully blocked. Filter
// here rather than catching, so a closed or already-frozen account is a skip, not noise.
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(account.getStatus())) {
continue;
}
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus)
|| SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)) {
frozen.add(String.valueOf(account.getId()));
continue;
}
try {
account.blockDebits(account.getSubStatus());
savingsAccountRepository.save(account);
frozen.add(String.valueOf(account.getId()));
LOG.warn("Sanctions alert {}: debits blocked on savings account {} of client {}", alertId, account.getId(),
clientId);
} catch (Exception e) {
// One un-freezable account must not abort the freeze of the others.
LOG.error("Sanctions alert {}: could not block debits on savings account {}: {}", alertId, account.getId(),
e.getMessage());
}
}
return frozen.stream().collect(Collectors.joining(","));
}
/**
* Releases the debit block. Only ever called on de-listing or revocation of the order, and
* only from the Principal Officer approval path.
*/
@Transactional
public void unfreezeClientAccounts(final Long clientId) {
if (clientId == null) {
return;
}
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)
|| SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus))) {
continue;
}
try {
account.unblockDebits();
savingsAccountRepository.save(account);
LOG.info("Debits released on savings account {} of client {}", account.getId(), clientId);
} catch (Exception e) {
LOG.error("Could not release debits on savings account {}: {}", account.getId(), e.getMessage());
}
}
}
}
@@ -59,4 +59,77 @@ public interface SanctionsScreeningService {
* Search screening logs by screened name.
*/
List<SanctionsScreeningLog> searchScreeningLogs(String name);
/**
* Alerts awaiting or carrying a disposition. Pass null for every alert regardless of state.
*/
List<SanctionsScreeningLog> getAlerts(String disposition);
/**
* Records the reviewing officer's assessment of a flagged screening.
*
* @param logId the flagged screening log id
* @param disposition FALSE_POSITIVE or CONFIRMED_MATCH
* @param reason mandatory narrative of the identifier comparison
*/
SanctionsScreeningLog recordDisposition(Long logId, String disposition, String reason);
/**
* Principal Officer sign-off. Approving a FALSE_POSITIVE is what actually unblocks
* onboarding; approving a CONFIRMED_MATCH triggers the freeze of the client's accounts.
*/
SanctionsScreeningLog approveDisposition(Long logId);
/**
* Records the FIU-IND STR reference and the date the nodal officer was informed, closing
* out the 24-hour reporting obligation on a confirmed match.
*/
SanctionsScreeningLog recordReporting(Long logId, String strReference, String nodalOfficerInformedOn);
/**
* True when this exact name carries a false positive that the Principal Officer approved —
* the only condition under which a matched name may be onboarded.
*/
boolean hasApprovedFalsePositive(String name);
/**
* Match check that does NOT write an activity log entry.
*
* <p>
* Used by the server-side onboarding gate, which runs on every save attempt: logging there
* would stack a fresh alert onto the queue each time the officer retries a blocked client,
* burying the one real case under duplicates. The screening that belongs in the log is the
* one the officer deliberately ran from the UI.
* </p>
*/
boolean isNameSanctioned(String name);
/**
* Re-screens every existing client against the currently loaded list snapshot, opening an
* alert for each hit. This is what makes freezing reachable at all: without it a customer
* added to the list after onboarding is never detected.
*
* @return number of clients that produced a new flagged alert
*/
int rescreenExistingClients();
/**
* Display name and account number for the given client ids, keyed by id. Resolved in one
* query so a queue of alerts does not issue a round trip per row.
*/
java.util.Map<Long, String[]> resolveClientNames(java.util.Collection<Long> clientIds);
/**
* Disposition of the client's most recent flagged alert (PENDING_REVIEW / FALSE_POSITIVE /
* CONFIRMED_MATCH), or null when they carry none. Drives the badge on the client's own page.
*/
String getLatestAlertStatus(Long clientId);
/**
* Confirmed matches carrying an STR obligation — the sanctions stream shown on the FIU-IND
* screen, deliberately separate from transaction-driven AML alerts.
*
* @param status PENDING_FILING or FILED; null for all
*/
List<SanctionsScreeningLog> getStrCases(String status);
}
@@ -18,10 +18,19 @@
*/
package org.apache.fineract.portfolio.sanctions.service;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.sanctions.data.SanctionsMatchData;
import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult;
import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias;
@@ -31,6 +40,7 @@ import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog;
import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,6 +52,9 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
private final SanctionsEntryRepository sanctionsEntryRepository;
private final SanctionsScreeningLogRepository screeningLogRepository;
private final SanctionsFreezeService sanctionsFreezeService;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
@@ -113,6 +126,288 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
return screeningLogRepository.findByScreenedNameContaining(name);
}
// ── Alert disposition ─────────────────────────────────────────────────────
@Override
@Transactional(readOnly = true)
public List<SanctionsScreeningLog> getAlerts(final String disposition) {
if (disposition == null || disposition.isBlank()) {
return screeningLogRepository.findAllAlerts();
}
return screeningLogRepository.findByDisposition(disposition.toUpperCase());
}
@Override
@Transactional
public SanctionsScreeningLog recordDisposition(final Long logId, final String disposition, final String reason) {
final SanctionsScreeningLog log = findAlertOrThrow(logId);
final String normalised = disposition == null ? "" : disposition.trim().toUpperCase();
if (!SanctionsScreeningLog.DISPOSITION_FALSE_POSITIVE.equals(normalised)
&& !SanctionsScreeningLog.DISPOSITION_CONFIRMED.equals(normalised)) {
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.disposition.invalid",
"Disposition must be FALSE_POSITIVE or CONFIRMED_MATCH.");
}
// The written reason IS the compliance record — an unexplained "not the same person" is
// exactly what a regulator would treat as no review having happened at all.
if (reason == null || reason.trim().length() < 10) {
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.disposition.reason.required",
"Record which identifiers were compared and why (minimum 10 characters).");
}
log.recordDisposition(normalised, reason.trim(), currentUserId());
screeningLogRepository.saveAndFlush(log);
LOG.warn("Sanctions alert {} for '{}' dispositioned as {}", logId, log.getScreenedName(), normalised);
return log;
}
@Override
@Transactional
public SanctionsScreeningLog approveDisposition(final Long logId) {
final SanctionsScreeningLog log = findAlertOrThrow(logId);
if (SanctionsScreeningLog.DISPOSITION_PENDING.equals(log.getDisposition())) {
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.reviewed",
"This alert has not been reviewed yet — record a disposition before approving.");
}
final Long approverId = currentUserId();
// Maker-checker: whoever assessed the hit cannot also sign it off.
if (approverId != null && approverId.equals(log.getReviewedBy())) {
// Naming the reviewer turns a dead end into an instruction: the officer can see at a
// glance that they are the maker and that somebody else has to sign it off.
final String reviewer = resolveUsername(log.getReviewedBy());
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.self.approval",
"This alert was assessed by '" + reviewer + "'. A different officer with the "
+ "APPROVE_SANCTIONS permission must approve it.",
reviewer);
}
log.approve(approverId);
// Approving a confirmed match is the moment the freeze becomes due. Onboarding matches
// have no clientId and therefore nothing to freeze — that case is blocked at creation.
if (log.isConfirmedMatch() && log.getClientId() != null) {
final String frozen = sanctionsFreezeService.freezeClientAccounts(log.getClientId(), log.getId());
log.recordFreeze(frozen);
LOG.warn("Sanctions alert {}: froze accounts [{}] for client {}", logId, frozen, log.getClientId());
}
// A confirmed match is reportable under PMLA, so approval opens the STR obligation and the
// case becomes visible on the FIU-IND screen. Kept on this row rather than pushed into the
// AML alert pipeline — that pipeline is transaction-driven and this is identity-driven.
if (log.isConfirmedMatch()) {
log.raiseStr();
}
// A cleared false positive releases any freeze the alert previously imposed.
if (log.isApprovedFalsePositive() && log.getFrozenAccountIds() != null && !log.getFrozenAccountIds().isBlank()) {
sanctionsFreezeService.unfreezeClientAccounts(log.getClientId());
log.recordFreeze("");
}
screeningLogRepository.saveAndFlush(log);
return log;
}
@Override
@Transactional
public SanctionsScreeningLog recordReporting(final Long logId, final String strReference,
final String nodalOfficerInformedOn) {
final SanctionsScreeningLog log = findAlertOrThrow(logId);
if (!log.isConfirmedMatch()) {
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.reporting.not.confirmed",
"Only a confirmed match is reported to FIU-IND and the nodal officer.");
}
log.recordReporting(strReference, parseDateTime(nodalOfficerInformedOn));
screeningLogRepository.saveAndFlush(log);
return log;
}
@Override
@Transactional(readOnly = true)
public boolean hasApprovedFalsePositive(final String name) {
if (name == null || name.isBlank()) {
return false;
}
return screeningLogRepository.countApprovedFalsePositives(name.trim()) > 0;
}
@Override
@Transactional(readOnly = true)
public boolean isNameSanctioned(final String name) {
if (name == null || name.isBlank()) {
return false;
}
return !sanctionsEntryRepository.searchByNameOrAlias(name.trim()).isEmpty();
}
@Override
@Transactional
public int rescreenExistingClients() {
// display_name is denormalised from the name parts and can drift out of sync with them —
// a direct DB edit, or any write path that misses it, leaves it stale. Screening the
// stale value would silently clear a client whose actual name is on the list, so the
// name parts are rebuilt here and display_name used only as the fallback.
final List<Map<String, Object>> clients = jdbcTemplate.queryForList(
"SELECT id, firstname, middlename, lastname, fullname, display_name FROM m_client "
+ "WHERE status_enum = 300");
int flagged = 0;
for (final Map<String, Object> row : clients) {
final Long clientId = ((Number) row.get("id")).longValue();
final String name = resolveScreenableName(row);
if (name == null || name.isBlank()) {
continue;
}
// Re-running the sweep must not stack another identical alert onto the queue. An open
// alert for this client is the same case still awaiting the same decision; repeating
// it buries the real work under copies of itself.
if (hasOpenAlert(clientId)) {
continue;
}
try {
final SanctionsScreeningResult result = screenName(name, "PERIODIC_RESCREEN", clientId, null);
if (result.isMatchFound()) {
flagged++;
}
} catch (Exception e) {
LOG.error("Re-screening failed for client {} ('{}'): {}", clientId, name, e.getMessage());
}
}
LOG.info("Periodic re-screening complete: {} of {} clients flagged", flagged, clients.size());
return flagged;
}
@Override
@Transactional(readOnly = true)
public Map<Long, String[]> resolveClientNames(final Collection<Long> clientIds) {
final Map<Long, String[]> byId = new HashMap<>();
if (clientIds == null || clientIds.isEmpty()) {
return byId;
}
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
clientIds.toArray());
for (final Map<String, Object> row : rows) {
final Long id = ((Number) row.get("id")).longValue();
byId.put(id, new String[] { (String) row.get("display_name"), (String) row.get("account_no") });
}
return byId;
}
@Override
@Transactional(readOnly = true)
public String getLatestAlertStatus(final Long clientId) {
if (clientId == null) {
return null;
}
// findFlaggedByClientId already orders newest first.
return screeningLogRepository.findFlaggedByClientId(clientId).stream().map(SanctionsScreeningLog::getDisposition)
.filter(java.util.Objects::nonNull).findFirst().orElse(null);
}
@Override
@Transactional(readOnly = true)
public List<SanctionsScreeningLog> getStrCases(final String status) {
final String normalised = status == null || status.isBlank() ? null : status.trim().toUpperCase();
return screeningLogRepository.findStrCases(normalised);
}
/** Username for a user id, falling back to the id when it cannot be resolved. */
private String resolveUsername(final Long userId) {
if (userId == null) {
return "unknown";
}
try {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT username FROM m_appuser WHERE id = ?", userId);
return rows.isEmpty() ? ("user " + userId) : (String) rows.get(0).get("username");
} catch (Exception e) {
return "user " + userId;
}
}
/** True when the client already has a flagged alert nobody has dispositioned yet. */
private boolean hasOpenAlert(final Long clientId) {
return screeningLogRepository.findFlaggedByClientId(clientId).stream().anyMatch(log -> {
// Unassessed — still the same case awaiting the same decision.
if (SanctionsScreeningLog.DISPOSITION_PENDING.equals(log.getDisposition())) {
return true;
}
// Assessed but not yet signed off, or a standing confirmed match. Checking only for
// PENDING_REVIEW left a hole: the moment an officer recorded an assessment the guard
// stopped seeing the alert, so every later sweep raised a fresh duplicate of a case
// that was already being worked — or already confirmed and frozen.
if (log.getApprovedBy() == null) {
return true;
}
return log.isConfirmedMatch();
});
}
/**
* The name to screen for a client row, built from the authoritative name columns.
*
* <p>
* An entity carries {@code fullname}; a person carries first/middle/last. Both are the source
* of truth, whereas {@code display_name} is a derived copy that can lag behind them. Falling
* back to it only when no name part exists keeps legacy rows screenable without trusting a
* value that may be stale.
* </p>
*/
private String resolveScreenableName(final Map<String, Object> row) {
final String fullname = trimToNull((String) row.get("fullname"));
if (fullname != null) {
return fullname;
}
final String joined = Stream.of("firstname", "middlename", "lastname").map(col -> trimToNull((String) row.get(col)))
.filter(part -> part != null).collect(Collectors.joining(" "));
if (!joined.isBlank()) {
return joined;
}
return trimToNull((String) row.get("display_name"));
}
private String trimToNull(final String value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private SanctionsScreeningLog findAlertOrThrow(final Long logId) {
final SanctionsScreeningLog log = screeningLogRepository.findById(logId)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.found",
"Sanctions alert " + logId + " does not exist."));
if (!log.isFlagged()) {
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.flagged",
"Screening " + logId + " produced no match, so there is nothing to disposition.");
}
return log;
}
private Long currentUserId() {
try {
return context.authenticatedUser().getId();
} catch (Exception e) {
// Re-screening runs from a scheduler with no authenticated user.
return null;
}
}
private OffsetDateTime parseDateTime(final String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return LocalDate.parse(value.trim()).atStartOfDay().atOffset(ZoneOffset.UTC);
} catch (DateTimeParseException e) {
LOG.debug("Could not parse nodal officer date: {}", value);
return null;
}
}
private Long saveLog(final String screenedName, final String result, final int matchCount,
final String matchedEntryIds, final String matchedNames, final String source,
final Long clientId, final String ipAddress) {
@@ -18,6 +18,11 @@
*/
package org.apache.fineract.portfolio.savings.api;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
@@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
@@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiResource;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
@@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Path("/v1/currentaccounts")
@@ -84,6 +99,10 @@ public class CurrentAccountsApiResource {
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
private final DocumentApiResource documentApiResource;
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
@GET
@Path("template")
@@ -337,4 +356,54 @@ public class CurrentAccountsApiResource {
return this.toApiJsonSerializer.serialize(result);
}
// ── Documents ────────────────────────────────────────────────────────────
// The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a
// chance to handle current-account requests: this class's own literal /v1/currentaccounts path
// is matched first for anything under {accountId}, and it has no "documents" method, so the
// request dead-ends in a 404 instead of falling through. These delegate straight to the same
// DocumentApiResource so current accounts get identical document-management behaviour.
@GET
@Path("{accountId}/documents")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List documents attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public List<DocumentData> retrieveDocuments(@PathParam("accountId") final Long accountId) {
return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId);
}
@GET
@Path("{accountId}/documents/{documentId}/attachment")
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
@Operation(summary = "Download a document attached to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) {
return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
@POST
@Path("{accountId}/documents")
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Attach a document to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId,
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name,
description);
}
@DELETE
@Path("{accountId}/documents/{documentId}")
@Operation(summary = "Remove a document attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId,
@PathParam("documentId") final Long documentId) {
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
}
@@ -29,6 +29,10 @@ import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.List;
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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
@@ -42,6 +46,7 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
private final PlatformSecurityContext context;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class,
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
@@ -77,11 +82,11 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME);
final FdInterestRateSaveRequest request = gson.fromJson(apiRequestBodyAsJson, FdInterestRateSaveRequest.class);
final Long createdById = this.context.authenticatedUser().getId();
final FdInterestRateEffectiveData saved = this.fdBaseInterestRateReadPlatformService.saveInterestRates(request, createdById);
return gson.toJson(saved);
public CommandProcessingResult saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.createCbsBaseInterestRate()
.withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -435,7 +435,10 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public Money activateWithBalance() {
return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount());
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of the
// deposit amount configured on the account's term/pre-closure details.
return Money.zero(this.currency);
}
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
@@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount {
@Override
public Money activateWithBalance() {
return Money.of(this.currency, this.minRequiredOpeningBalance);
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of any
// minimum required opening balance the product has configured.
return Money.zero(this.currency);
}
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
@@ -19,7 +19,6 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
@@ -455,18 +454,10 @@ public class SavingsAccountAssembler {
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
// Account Facility Config
if (element.getAsJsonObject().has(accountFacilityConfigParamName)) {
final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName);
if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) {
SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig();
if (facilityConfig == null) {
facilityConfig = new SavingsAccountFacilityConfig(account);
account.setFacilityConfig(facilityConfig);
}
facilityConfig.updateFrom(configEl.getAsJsonObject());
}
}
// Every digital facility starts disabled at account opening, regardless of anything the
// request sends or the product defaults to -- they're switched on deliberately, one at a
// time, later, through the account's own Edit Nominee & Features screen.
account.setFacilityConfig(new SavingsAccountFacilityConfig(account));
// Joint holders
if (element.getAsJsonObject().has(jointHoldersParamName)) {
@@ -0,0 +1,42 @@
/**
* 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.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CBSBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler {
private final FdBaseInterestRateWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.saveInterestRates(command);
}
}
@@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
}
// Minimum required balance validation
// Minimum required balance is not enforced -- a withdrawal must never be blocked for
// taking the account below its configured minimum, only for exceeding the available
// balance (checked above).
final BigDecimal newBalance = currentBalance.subtract(amount);
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
throw new GeneralPlatformDomainRuleException(
"error.msg.current.account.min.required.balance.breached",
"Withdrawal of " + amount + " would breach the minimum required balance of "
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
accountId);
}
}
final String refNo = command.stringValueOfParameterNamed("accountNumber");
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
@@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
}
// Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig"
JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig");
if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig");
if (fcJson != null) {
account.setFacilityConfig(assembleFacilityConfig(account, fcJson));
}
// Every digital facility starts disabled at account opening, regardless of anything the
// request sends or the product defaults to -- they're switched on deliberately, one at a
// time, later, through the account's own Edit Nominee & Features screen.
account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account));
// Joint holders
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
@@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
final JsonObject json = command.parsedJson().getAsJsonObject();
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// Validate minimum opening balance before activation
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {
// intentionally ignored
}
}
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
&& openingBalanceValue.compareTo(minRequired) < 0) {
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
"validation.msg.currentaccount.openingBalance.less.than.min.required",
openingBalanceValue, minRequired)));
}
account.activate(activatedOnDate, user);
applyFacilityChargesOnActivation(account);
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO;
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>();
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
// Opening balance deposit transaction
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
try {
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
deposit.setRunningBalanceDerived(openingBalanceValue);
account.setTotalDepositsDerived(openingBalanceValue);
currentAccountTransactionRepository.saveAndFlush(deposit);
currentBalance = openingBalanceValue;
depositTxns.add(deposit);
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
} catch (Exception e) {
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
}
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
account.getCharges().stream()
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
.toList();
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
account.setAccountBalanceDerived(currentBalance);
// Activation posts no transaction of its own -- no opening-balance deposit, no
// digital-facility charges applied or collected, regardless of what the account's facility
// config or product defaults say. Funding and any facility charges happen strictly
// afterwards, through separate transactions a clerk processes once the account is already
// active.
currentAccountRepository.saveAndFlush(account);
// Post GL journal entries — deposits in batch, each charge individually
postGlEntries(account, depositTxns, chargeEntries);
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
}
@@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
// Use account-level facility config — reflects what was selected at account opening time
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
account.getFacilityConfig();
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
account.getId(), config != null ? "FOUND" : "NULL");
if (config == null) {
// Fallback: use product-level facility config
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
config = currentProductRepository.findById(account.getProductId())
.map(p -> {
final var pc = p.getFacilityConfig();
if (pc == null) {
log.info("[FacilityCharge] Product has no facility config either");
return null;
}
// Map product config to account config fields inline
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
return ac;
}).orElse(null);
}
if (config == null) {
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
return;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
// Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
// when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
// the facility enabled.
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
}
// Wrapper to carry a charge transaction together with its charge entity for GL posting
private record ChargeGlEntry(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
@@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
/**
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
* posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and
* {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the
* charge-collection logic isn't duplicated. Returns the resulting balance.
* posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) --
* activation itself no longer collects any charges. Returns the resulting balance.
*/
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
@@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
if (!enabled) {
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
return;
}
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
return;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
return;
}
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
account.getCharges().add(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
.createFromFacility(account, charge));
}
}
@@ -172,8 +172,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (portfolioAccountData == null) {
final PaymentDetail paymentDetail = null;
this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(),
amountForDeposit.getAmount(), paymentDetail);
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt,
account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail);
// handleFDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes, e.g. an activation charge payment) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else {
final SavingsAccount fromSavingsAccount = null;
boolean isRegularTransaction = false;
@@ -202,8 +208,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
if (amountForDeposit.isGreaterThanZero()) {
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
} else {
// No deposit has actually landed on the account yet -- project maturity using the
// configured deposit amount (the same approach used for the pre-activation preview and
// for reinvested FDs) instead of the account's real, still-empty transaction history,
// which would otherwise price in zero interest.
account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
@@ -275,8 +290,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
.retriveSavingsLinkedAssociation(savingsId);
if (portfolioAccountData == null) {
this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(),
amountForDeposit.getAmount(), null, isRegularTransaction);
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt,
account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction);
// handleRDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else {
final boolean isExceptionForBalanceCheck = false;
final SavingsAccount fromSavingsAccount = null;
@@ -368,6 +389,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail);
// The account may have been activated with no funds yet (opening deposit is now a separate,
// later step) -- in that case its maturity amount was only ever projected off the configured
// deposit amount, with zero real transactions. Now that money has actually landed, recompute
// maturity date/amount off the account's real transaction history so it reflects reality.
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"DEPOSIT");
@@ -11,10 +11,16 @@
*/
package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
@@ -51,7 +57,9 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
@Override
@Transactional
public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) {
final String fdType = request.getFdType();
validateRequest(request);
final String fdType = request.getFdType().toUpperCase();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<FdBaseInterestRateEffective> activeVersions = effectiveRepository
@@ -85,6 +93,125 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
return toData(newVersion);
}
private void validateRequest(final FdInterestRateSaveRequest request) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
if (request == null) {
validator.failWithCodeNoParameterAddedToErrorCode("request.cannot.be.null");
throwExceptionIfValidationWarningsExist(errors);
return;
}
final String fdType = request.getFdType();
if (fdType == null || fdType.isBlank()) {
validator.parameter("fdType").failWithCode("cannot.be.blank");
} else if (!List.of("SINGLE", "DOUBLE", "SPECIAL").contains(fdType.toUpperCase())) {
validator.parameter("fdType").value(fdType).failWithCode("invalid.fd.type");
}
if (request.getSlabs() == null || request.getSlabs().isEmpty()) {
validator.parameter("slabs").failWithCode("cannot.be.blank");
}
if (request.getVersionLabel() != null && request.getVersionLabel().length() > 100) {
validator.parameter("versionLabel").value(request.getVersionLabel()).failWithCode("exceeds.max.length");
}
throwExceptionIfValidationWarningsExist(errors);
validateSlabs(request.getSlabs());
}
private void validateSlabs(final List<FdInterestRateSaveRequest.SlabRequest> slabs) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
if (slabs == null || slabs.isEmpty()) {
validator.parameter("slabs").failWithCode("cannot.be.blank");
throwExceptionIfValidationWarningsExist(errors);
return;
}
final List<FdInterestRateSaveRequest.SlabRequest> sorted = new ArrayList<>(slabs);
sorted.sort(Comparator.comparing((FdInterestRateSaveRequest.SlabRequest slab) -> slab.getSrNo() == null ? Integer.MAX_VALUE : slab.getSrNo())
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
final Integer expectedPeriodType = sorted.get(0).getPeriodTypeEnum();
Integer expectedFrom = 1;
for (int i = 0; i < sorted.size(); i++) {
final FdInterestRateSaveRequest.SlabRequest slab = sorted.get(i);
final int srNo = slab.getSrNo() == null ? i + 1 : slab.getSrNo();
final String slabPrefix = "slabs[" + srNo + "].";
if (slab.getPeriodTypeEnum() == null) {
validator.parameter(slabPrefix + "periodTypeEnum").failWithCode("cannot.be.blank");
} else if (!slab.getPeriodTypeEnum().equals(expectedPeriodType)) {
validator.parameter(slabPrefix + "periodTypeEnum").value(slab.getPeriodTypeEnum())
.failWithCode("must.be.consistent.across.all.slabs");
}
final Integer fromPeriod = slab.getFromPeriod();
if (fromPeriod == null) {
validator.parameter(slabPrefix + "fromPeriod").failWithCode("cannot.be.blank");
} else if (fromPeriod < 0) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.non.negative");
} else if (!fromPeriod.equals(expectedFrom)) {
if (i == 0) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("first.slab.must.start.at.one");
} else if (fromPeriod < expectedFrom) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.not.overlap.previous.range");
} else {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.continuous.with.previous.range");
}
}
final Integer toPeriod = slab.getToPeriod();
if (i < sorted.size() - 1) {
if (toPeriod == null) {
validator.parameter(slabPrefix + "toPeriod").failWithCode("cannot.be.blank.for.intermediate.slab");
} else {
if (toPeriod < 0) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
}
if (fromPeriod != null && toPeriod < fromPeriod) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
}
expectedFrom = toPeriod + 1;
}
} else {
if (toPeriod != null) {
if (toPeriod < 0) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
}
if (fromPeriod != null && toPeriod < fromPeriod) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
}
}
}
final BigDecimal annualRate = slab.getAnnualInterestRate();
if (annualRate == null) {
validator.parameter(slabPrefix + "annualInterestRate").failWithCode("cannot.be.blank");
} else if (annualRate.compareTo(BigDecimal.ZERO) < 0) {
validator.parameter(slabPrefix + "annualInterestRate").value(annualRate).failWithCode("must.be.non.negative");
}
final BigDecimal seniorRate = slab.getSeniorCitizenRate();
if (seniorRate != null && seniorRate.compareTo(BigDecimal.ZERO) < 0) {
validator.parameter(slabPrefix + "seniorCitizenRate").value(seniorRate).failWithCode("must.be.non.negative");
}
}
throwExceptionIfValidationWarningsExist(errors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) {
List<FdInterestRateSlabData> slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream()
.map(s -> FdInterestRateSlabData.builder()
@@ -0,0 +1,27 @@
/**
* 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 org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface FdBaseInterestRateWritePlatformService {
CommandProcessingResult saveInterestRates(JsonCommand command);
}
@@ -0,0 +1,49 @@
/**
* 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 com.google.gson.Gson;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FdBaseInterestRateWritePlatformServiceImpl implements FdBaseInterestRateWritePlatformService {
private final PlatformSecurityContext context;
private final FdBaseInterestRateReadPlatformService readPlatformService;
private final Gson gson = new Gson();
@Override
public CommandProcessingResult saveInterestRates(final JsonCommand command) {
final FdInterestRateSaveRequest request = gson.fromJson(command.json(), FdInterestRateSaveRequest.class);
final Long createdById = this.context.authenticatedUser().getId();
final FdInterestRateEffectiveData saved = this.readPlatformService.saveInterestRates(request, createdById);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withEntityId(saved.getId())
.build();
}
}
@@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
// 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching
// GL journal entries for those charge transactions (collectCharges() only books them
// against the account itself; it does not touch the general ledger).
if (!newlyEnabledFacilityTypes.isEmpty()) {
final Set<Long> existingTransactionIds = new HashSet<>(account.findExistingTransactionIds());
final Set<Long> existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds());
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds,
existingReversedTransactionIds, false);
}
// 3) Apply the facility-config toggles to the real row.
@@ -97,7 +97,6 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final GSIMRepositoy gsimRepository;
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional
@@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Validate minimum opening balance
BigDecimal openingBalanceForValidation = BigDecimal.ZERO;
if (command.parameterExists("openingBalance")) {
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
if (ob != null) openingBalanceForValidation = ob;
}
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
final List<ApiParameterError> errors = new ArrayList<>();
errors.add(ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
openingBalanceForValidation, minRequiredOpening));
throw new PlatformApiDataValidationException(errors);
}
// Minimum required opening balance is not enforced -- activation must never be blocked
// for not meeting it; the account can be funded afterwards by whatever deposit the
// customer brings (cash or a transfer), on their own timeline.
checkClientOrGroupActive(account);
final Set<Long> existingTransactionIds = new HashSet<>();
@@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
// Auto-apply facility charges based on product facility config
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Collect any newly added facility activation charges (after flush so amountOutstanding is set)
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
facilityChargeAutoApplyService.collectActivationCharges(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Activation posts no transaction of its own -- digital-facility charges are no longer
// auto-applied and auto-collected here. Whatever facilities are turned on later get
// charged (if at all) through the normal charge/collection flow at that time, not as a
// side effect of activation.
}
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
@@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
}
@Override
public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
}
@Override
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
@@ -169,6 +169,8 @@ import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatf
import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster;
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask;
import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService;
@@ -200,6 +202,14 @@ public class SavingsConfiguration {
return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository);
}
@Bean
@ConditionalOnMissingBean(FdBaseInterestRateWritePlatformService.class)
public FdBaseInterestRateWritePlatformService fdBaseInterestRateWritePlatformService(
PlatformSecurityContext context,
FdBaseInterestRateReadPlatformService readPlatformService) {
return new FdBaseInterestRateWritePlatformServiceImpl(context, readPlatformService);
}
@Bean
@ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class)
public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context,
@@ -463,8 +473,7 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
DenominationBreakdownWriter denominationBreakdownWriter) {
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
@@ -472,7 +481,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
errorHandler, denominationBreakdownWriter);
}
@Bean
@@ -0,0 +1,63 @@
/**
* 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.shareaccounts.jobs.forfeitunclaimeddividends;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ForfeitUnclaimedDividendsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ShareAccountSchedularService shareAccountSchedularService;
@Bean
protected Step forfeitUnclaimedDividendsStep() {
return new StepBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository)
.tasklet(forfeitUnclaimedDividendsTasklet(), transactionManager).build();
}
@Bean
public Job forfeitUnclaimedDividendsJob() {
return new JobBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository).start(forfeitUnclaimedDividendsStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public ForfeitUnclaimedDividendsTasklet forfeitUnclaimedDividendsTasklet() {
return new ForfeitUnclaimedDividendsTasklet(jdbcTemplate, shareAccountSchedularService);
}
}
@@ -0,0 +1,67 @@
/**
* 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.shareaccounts.jobs.forfeitunclaimeddividends;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
* forfeited — the hold on the member's savings account is released and the amount is swept back
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
* the actual GL/balance mechanics.
*/
@Slf4j
@RequiredArgsConstructor
public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
private final ShareAccountSchedularService shareAccountSchedularService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final List<Throwable> exceptions = new ArrayList<>();
final List<Long> lockIds = this.jdbcTemplate.queryForList(
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
for (final Long lockId : lockIds) {
try {
this.shareAccountSchedularService.forfeitDividendLock(lockId);
} catch (final Exception e) {
log.error("Forfeit Unclaimed Dividends failed for share dividend lock id: {}", lockId, e);
exceptions.add(e);
}
}
if (!exceptions.isEmpty()) {
throw new JobExecutionException(exceptions);
}
return RepeatStatus.FINISHED;
}
}
@@ -22,4 +22,11 @@ public interface ShareAccountSchedularService {
void postDividend(Long dividendDetailId, Long savingsId);
/**
* Writes off an unclaimed share-dividend liability (see {@code m_share_dividend_lock}) to the
* bank's profit — called once a lock's 3-year release_due_date has passed with no member claim
* or manager action.
*/
void forfeitDividendLock(Long lockId);
}
@@ -18,8 +18,12 @@
*/
package org.apache.fineract.portfolio.shareaccounts.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
@@ -27,14 +31,22 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendDetails;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendStatusType;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
private final ShareAccountDividendRepository shareAccountDividendRepository;
private final SavingsAccountDomainService savingsAccountDomainService;
private final SavingsAccountAssembler savingsAccountAssembler;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
@@ -49,4 +61,69 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
this.shareAccountDividendRepository.saveAndFlush(shareAccountDividendDetails);
}
/**
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
* account up front -- it stays purely a "Dividend Payable" GL liability (see
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
* dividend past its 3-year deadline has no savings hold to release and no account balance to
* deduct -- the liability is simply written off directly against the bank's profit.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void forfeitDividendLock(final Long lockId) {
final Map<String, Object> lock = this.jdbcTemplate.queryForList("""
SELECT dividend_detail_id, currency_code, amount, status
FROM m_share_dividend_lock WHERE id = ?
""", lockId).stream().findFirst().orElseThrow();
if (!"UNCLAIMED".equals(lock.get("status"))) {
return;
}
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal amount = (BigDecimal) lock.get("amount");
postForfeitureJournalEntry(lockId, currencyCode, amount);
this.jdbcTemplate.update(
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
}
/**
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
* Reads currency straight off the lock row (set from the share account at creation time) --
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
* both GL accounts yet, exactly like the declaration-time Reserve entry.
*/
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
final Long payableDividendsAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long forfeitedIncomeAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVFORFEIT" + lockId;
final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
}
@@ -36,6 +36,7 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
@@ -112,8 +113,10 @@ public class ShareAccountsConfiguration {
@Bean
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler);
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
context, jdbcTemplate);
}
@Bean
@@ -42,6 +42,15 @@ public class ShareProductDividentPayOutDetailsRepositoryWrapper {
this.shareProductDividentPayOutDetailsRepository.save(shareProductDividendPayOutDetails);
}
/**
* Like {@link #save}, but flushes immediately so the generated id is guaranteed to be
* populated on the entity before the caller reads it (plain save() can leave a newly
* INSERTed entity's identity-generated id unset until the surrounding transaction flushes).
*/
public void saveAndFlush(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(shareProductDividendPayOutDetails);
}
public void delete(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
this.shareProductDividentPayOutDetailsRepository.delete(shareProductDividendPayOutDetails);
}
@@ -115,6 +115,13 @@ public class ShareProductDividendAssembler {
numberOfShares += purchasedSharesData.getNumberOfShares();
} else {
numberOfShares -= purchasedSharesData.getNumberOfShares();
// A purchase that hadn't yet cleared the Minimum Active Period is skipped
// above (via `continue`) and never added here — but its later full/partial
// redemption is never skipped, so without this floor numberOfShares (and
// therefore this account's dividend) can go negative.
if (numberOfShares < 0) {
numberOfShares = 0;
}
}
}
@@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMappe
import com.google.gson.JsonObject;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -85,6 +86,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final CommandSourceRepository commandSourceRepository;
private final JdbcTemplate jdbcTemplate;
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
// accounting module into this constructor just for two int constants.
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
@Override
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
@@ -152,12 +159,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final BigDecimal dividendAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
if (dividendPayOutDetails == null) {
throw new DividentProcessingException("eligible.shares.not.found", "No eligible shares for creating dividends");
}
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount);
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
@@ -172,6 +184,68 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
}
/**
* A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see
* {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is
* a real reservation of that period's profit. Two declarations covering the same days would
* pay members twice for the same period, so any overlap against an existing declaration for
* this product is blocked outright.
*/
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
final LocalDate dividendPeriodEndDate) {
final Integer overlappingCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_share_product_dividend_pay_out
WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ?
""", Integer.class, productId, java.sql.Date.valueOf(dividendPeriodEndDate),
java.sql.Date.valueOf(dividendPeriodStartDate));
if (overlappingCount != null && overlappingCount > 0) {
throw new DividentProcessingException("period.overlaps.existing.dividend",
"This product already has a dividend declared for a period that overlaps " + dividendPeriodStartDate + " to "
+ dividendPeriodEndDate + ". Choose a period that doesn't overlap with an existing declaration.");
}
}
/**
* Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own
* Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just
* silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry
* pair rather than through the transaction-bound AccountingProcessorHelper (which expects a
* loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if
* the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity
* Mappings yet, so dividend declaration itself is never blocked by missing GL setup.
*/
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) {
final Long reserveAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
.stream().findFirst().orElse(null);
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (reserveAccountId == null || payableDividendsAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVRES" + dividendPayOutId;
final String description = "Reserve for Dividend declaration #" + dividendPayOutId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
// Debit reduces the equity Reserve; credit increases the Dividend Payable liability.
jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
@Override
public CommandProcessingResult approveShareProductDividend(final Long PayOutDetailId) {
try {
@@ -182,6 +256,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
dividendPayOutDetails.approveDividendPayout();
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
makeDividendsClaimable(PayOutDetailId);
return new CommandProcessingResultBuilder() //
.withEntityId(PayOutDetailId) //
.build();
@@ -191,6 +266,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
}
/**
* Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account.
* Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at
* declaration time) until the member actually comes to claim it -- at which point it can be
* split between clearing an overdue loan, a savings credit, or cash (see
* {@code DividendClaimRequestApiResource}). This just marks each member's share of the
* declaration POSTED (their entitlement is locked in, no more recalculation) and opens a
* 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather
* than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This
* is cheap enough (no per-member external command calls, just two bulk statements) to run
* synchronously in the approve request itself -- no background job needed.
*/
private void makeDividendsClaimable(final Long payOutDetailId) {
final LocalDate today = DateUtils.getBusinessLocalDate();
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
final java.sql.Date releaseDueDate = java.sql.Date.valueOf(today.plusYears(3));
// A linked savings account is no longer required to create the unclaimed-liability
// record -- disbursement destination (loan / savings / cash) is chosen when the member
// actually claims it, not up front. So every member gets treated identically here, whether
// their share account has a linked savings account or not; currency is read straight off
// the share account itself.
jdbcTemplate.update("""
INSERT INTO m_share_dividend_lock
(dividend_detail_id, client_id, savings_account_id, currency_code, amount, locked_date, release_due_date,
status, created_date, last_modified_date)
SELECT sad.id, sa.client_id, sa.savings_account_id, sa.currency_code, sad.amount, ?, ?, 'UNCLAIMED', now(), now()
FROM m_share_account_dividend_details sad
JOIN m_share_account sa ON sa.id = sad.account_id
WHERE sad.dividend_pay_out_id = ? AND sad.status = 100 AND sad.amount > 0
""", lockedDate, releaseDueDate, payOutDetailId);
// A member whose entitlement worked out to zero has nothing to claim; everyone else is now
// covered by the unclaimed-liability row above -- both get marked posted.
jdbcTemplate.update("UPDATE m_share_account_dividend_details SET status = 300 WHERE dividend_pay_out_id = ? AND status = 100",
payOutDetailId);
}
@Override
public CommandProcessingResult deleteShareProductDividend(final Long PayOutDetailId) {
try {
@@ -117,11 +117,11 @@ public class ShareProductsConfiguration {
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
ProductPendingEditChargeRepository pendingEditChargeRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository);
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
}
}
@@ -28,6 +28,7 @@ import lombok.Data;
public class ClientWelcomeKitData implements Serializable {
private final String clientName;
private final boolean clientActive;
private final boolean canDispatchNew;
private final List<WelcomeKitDispatchData> dispatches;
}
@@ -31,10 +31,13 @@ public class WelcomeKitDispatchData implements Serializable {
private final Long clientId;
private final String clientName;
private final String deliveryAddress;
private final Long addressId;
private final String deliveryMode;
private final String deliveryModeOtherText;
private final String trackingId;
private final String status;
private final LocalDate dispatchedOnDate;
private final LocalDate deliveredOnDate;
private final LocalDate returnedOnDate;
private final String remarks;
}
@@ -39,6 +39,13 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@EntityListeners(AuditingEntityListener.class)
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
/** Kit handed over to the delivery partner; awaiting the outcome. */
public static final String STATUS_DISPATCHED = "DISPATCHED";
/** Delivery partner confirmed delivery, so the address stands verified. */
public static final String STATUS_DELIVERED = "DELIVERED";
/** Kit came back undelivered, so the address could not be verified. */
public static final String STATUS_RETURNED = "RETURNED";
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@@ -46,6 +53,13 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
@Column(name = "delivery_address", length = 500, nullable = false)
private String deliveryAddress;
/**
* The client address this kit was posted to. Kept as a plain id rather than an association because the
* address it points at may later be edited or removed, while the dispatch record must stay as it was.
*/
@Column(name = "address_id")
private Long addressId;
@Column(name = "delivery_mode", length = 50, nullable = false)
private String deliveryMode;
@@ -64,6 +78,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
@Column(name = "delivered_on_date")
private LocalDate deliveredOnDate;
@Column(name = "returned_on_date")
private LocalDate returnedOnDate;
@Column(name = "remarks", length = 500)
private String remarks;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@@ -84,11 +104,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
// Default constructor for JPA
}
private WelcomeKitDispatch(final Client client, final String deliveryAddress, final String deliveryMode,
private WelcomeKitDispatch(final Client client, final String deliveryAddress, final Long addressId, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final String status,
final LocalDate dispatchedOnDate) {
this.client = client;
this.deliveryAddress = deliveryAddress;
this.addressId = addressId;
this.deliveryMode = deliveryMode;
this.deliveryModeOtherText = deliveryModeOtherText;
this.trackingId = trackingId;
@@ -96,9 +117,10 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
this.dispatchedOnDate = dispatchedOnDate;
}
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
return new WelcomeKitDispatch(client, deliveryAddress, deliveryMode, deliveryModeOtherText, trackingId, "DISPATCHED", dispatchedOnDate);
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final Long addressId,
final String deliveryMode, final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
return new WelcomeKitDispatch(client, deliveryAddress, addressId, deliveryMode, deliveryModeOtherText, trackingId,
STATUS_DISPATCHED, dispatchedOnDate);
}
public Client getClient() {
@@ -109,10 +131,23 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
return this.status;
}
public void updateStatus(final String newStatus, final LocalDate deliveredOnDate) {
public String getTrackingId() {
return this.trackingId;
}
/**
* Records the outcome the operator read off the delivery partner's website or app. DELIVERED stamps the delivery
* date and clears any previous return date, RETURNED does the reverse, so the two dates can never both be set.
*/
public void updateStatus(final String newStatus, final LocalDate outcomeDate, final String remarks) {
this.status = newStatus;
if ("DELIVERED".equals(newStatus)) {
this.deliveredOnDate = deliveredOnDate;
if (STATUS_DELIVERED.equals(newStatus)) {
this.deliveredOnDate = outcomeDate;
this.returnedOnDate = null;
} else if (STATUS_RETURNED.equals(newStatus)) {
this.returnedOnDate = outcomeDate;
this.deliveredOnDate = null;
}
this.remarks = remarks;
}
}
@@ -25,4 +25,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
boolean existsByTrackingIdIgnoreCase(String trackingId);
}
@@ -22,10 +22,13 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.client.domain.ClientStatus;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatch;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@@ -43,20 +46,21 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
List<WelcomeKitDispatchData> dispatches = this.jdbcTemplate.query(sql, rm, clientId);
String clientName = this.jdbcTemplate.queryForObject("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId);
boolean canDispatchNew = true;
if (!dispatches.isEmpty()) {
WelcomeKitDispatchData latest = dispatches.get(0);
if ("RETURNED".equals(latest.getStatus())) {
canDispatchNew = true;
} else {
canDispatchNew = false;
}
final Map<String, Object> client = this.jdbcTemplate.queryForMap("SELECT display_name, status_enum FROM m_client WHERE id = ?",
clientId);
final String clientName = (String) client.get("display_name");
final boolean clientActive = ClientStatus.fromInt((Integer) client.get("status_enum")).isActive();
// A kit only goes out for an active client, and only one kit is in flight at a time: a fresh one can follow
// a returned kit (the address is being retried) but never a delivered or still-in-transit one.
boolean canDispatchNew = clientActive;
if (clientActive && !dispatches.isEmpty()) {
canDispatchNew = WelcomeKitDispatch.STATUS_RETURNED.equals(dispatches.get(0).getStatus());
}
return ClientWelcomeKitData.builder()
.clientName(clientName)
.clientActive(clientActive)
.canDispatchNew(canDispatchNew)
.dispatches(dispatches)
.build();
@@ -66,9 +70,10 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
public String schema() {
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
+ " w.delivery_address as deliveryAddress, w.delivery_mode as deliveryMode, "
+ " w.delivery_address as deliveryAddress, w.address_id as addressId, w.delivery_mode as deliveryMode, "
+ " w.delivery_mode_other_text as deliveryModeOtherText, w.tracking_id as trackingId, "
+ " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate "
+ " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate, "
+ " w.returned_on_date as returnedOnDate, w.remarks as remarks "
+ " FROM m_welcome_kit_dispatch w "
+ " JOIN m_client c ON c.id = w.client_id ";
}
@@ -79,24 +84,31 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
final Long clientId = rs.getLong("clientId");
final String clientName = rs.getString("clientName");
final String deliveryAddress = rs.getString("deliveryAddress");
// Nullable: kits dispatched before the address link existed carry no address id.
final Long addressId = JdbcSupport.getLong(rs, "addressId");
final String deliveryMode = rs.getString("deliveryMode");
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
final String trackingId = rs.getString("trackingId");
final String status = rs.getString("status");
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
final LocalDate returnedOnDate = JdbcSupport.getLocalDate(rs, "returnedOnDate");
final String remarks = rs.getString("remarks");
return WelcomeKitDispatchData.builder()
.id(id)
.clientId(clientId)
.clientName(clientName)
.deliveryAddress(deliveryAddress)
.addressId(addressId)
.deliveryMode(deliveryMode)
.deliveryModeOtherText(deliveryModeOtherText)
.trackingId(trackingId)
.status(status)
.dispatchedOnDate(dispatchedOnDate)
.deliveredOnDate(deliveredOnDate)
.returnedOnDate(returnedOnDate)
.remarks(remarks)
.build();
}
}
@@ -19,12 +19,12 @@
package org.apache.fineract.portfolio.welcomekit.service;
import java.time.LocalDate;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
@@ -48,9 +48,16 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
if (!client.isActive()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.client.must.be.active",
"A welcome kit can only be dispatched for an active client.");
}
final String deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
final String trackingId = command.stringValueOfParameterNamed("trackingId");
final Long addressId = command.longValueOfParameterNamed("addressId");
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
@@ -61,15 +68,29 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
"Delivery mode cannot be blank.");
}
if (trackingId == null || trackingId.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.trackingId.cannot.be.blank",
"Tracking ID cannot be blank.");
}
if (dispatchedOnDate == null) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
"Dispatched on date cannot be blank.");
}
if (dispatchedOnDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.in.the.future",
"Dispatched on date cannot be in the future.");
}
final String trackingId = "WK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
// The tracking ID is transcribed by hand from the delivery partner's receipt, so a typo could otherwise
// silently attach one client's consignment number to another client's kit.
final String sanitisedTrackingId = trackingId.trim();
if (this.welcomeKitDispatchRepository.existsByTrackingIdIgnoreCase(sanitisedTrackingId)) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.trackingId.duplicate",
"Tracking ID " + sanitisedTrackingId + " is already recorded against another welcome kit dispatch.");
}
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, deliveryMode, deliveryModeOtherText,
trackingId, dispatchedOnDate);
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, addressId, deliveryMode,
deliveryModeOtherText, sanitisedTrackingId, dispatchedOnDate);
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
@@ -94,12 +115,30 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
}
LocalDate deliveredOnDate = null;
if ("DELIVERED".equals(status)) {
deliveredOnDate = LocalDate.now();
final String newStatus = status.trim().toUpperCase();
if (!WelcomeKitDispatch.STATUS_DELIVERED.equals(newStatus) && !WelcomeKitDispatch.STATUS_RETURNED.equals(newStatus)) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.invalid",
"Status must be either " + WelcomeKitDispatch.STATUS_DELIVERED + " or " + WelcomeKitDispatch.STATUS_RETURNED + ".");
}
dispatch.updateStatus(status, deliveredOnDate);
// The outcome is recorded once. A kit that already came back is replaced by a fresh dispatch rather than
// being flipped to delivered after the fact.
if (!WelcomeKitDispatch.STATUS_DISPATCHED.equals(dispatch.getStatus())) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.outcome.already.recorded",
"This welcome kit is already marked as " + dispatch.getStatus() + ".");
}
// The outcome date is optional; the operator may only know the day they checked the courier's site.
LocalDate outcomeDate = command.localDateValueOfParameterNamed("outcomeDate");
if (outcomeDate == null) {
outcomeDate = DateUtils.getBusinessLocalDate();
}
if (outcomeDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.outcomeDate.in.the.future",
"Outcome date cannot be in the future.");
}
dispatch.updateStatus(newStatus, outcomeDate, command.stringValueOfParameterNamed("remarks"));
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
@@ -0,0 +1,79 @@
package org.apache.fineract.useradministration.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.data.AppUserPreferenceData;
import org.apache.fineract.useradministration.service.AppUserPreferenceReadPlatformService;
import org.apache.fineract.useradministration.service.AppUserPreferenceWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/users/{userId}/preferences")
@Component
@Tag(name = "User Preferences", description = "API to manage user specific preferences.")
@RequiredArgsConstructor
public class AppUserPreferencesApiResource {
private final PlatformSecurityContext context;
private final AppUserPreferenceReadPlatformService readPlatformService;
private final AppUserPreferenceWritePlatformService writePlatformService;
private void validateUserAuthorization(Long userId) {
if (!context.authenticatedUser().getId().equals(userId)) {
throw new NoAuthorizationException("Not authorized to access or modify preferences for another user.");
}
}
@GET
@Path("{preferenceName}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve user preference", description = "Retrieves a specific preference for the user.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class))) })
public AppUserPreferenceData getPreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName) {
validateUserAuthorization(userId);
String value = readPlatformService.getPreferenceValue(userId, preferenceName);
return new AppUserPreferenceData(preferenceName, value);
}
@PUT
@Path("{preferenceName}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update user preference", description = "Updates or creates a specific preference for the user.")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class)))
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public AppUserPreferenceData updatePreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName,
final AppUserPreferenceData preferenceData) {
validateUserAuthorization(userId);
// Ensure the path parameter matches the request body if the request body contains a name
String valueToSave = preferenceData != null ? preferenceData.getValue() : null;
if (valueToSave == null) {
throw new IllegalArgumentException("Preference value cannot be null");
}
writePlatformService.updatePreference(userId, preferenceName, valueToSave);
return new AppUserPreferenceData(preferenceName, valueToSave);
}
}
@@ -88,6 +88,11 @@ public class UsersApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
private final BulkImportWorkbookService bulkImportWorkbookService;
private final org.apache.fineract.useradministration.service.UserPendingWritePlatformService userPendingWritePlatformService;
private final org.apache.fineract.useradministration.service.UserPendingReadPlatformService userPendingReadPlatformService;
private final org.apache.fineract.infrastructure.core.serialization.FromJsonHelper fromApiJsonHelper;
private final org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService;
private final DefaultToApiJsonSerializer<org.apache.fineract.useradministration.data.UserPendingRequestData> pendingRequestApiJsonSerializer;
@GET
@Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n"
@@ -158,6 +163,12 @@ public class UsersApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.configurationDomainService.isMakerCheckerEnabled()) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(null, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.submitCreateUser(command);
return this.toApiJsonSerializer.serialize(result);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createUser() //
@@ -179,6 +190,12 @@ public class UsersApiResource {
@Produces({ MediaType.APPLICATION_JSON })
public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.configurationDomainService.isMakerCheckerEnabled()) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(userId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.submitUpdateUser(userId, command);
return this.toApiJsonSerializer.serialize(result);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateUser(userId) //
@@ -190,6 +207,56 @@ public class UsersApiResource {
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List User creations & edits pending approval")
public org.apache.fineract.infrastructure.core.service.Page<org.apache.fineract.useradministration.data.UserPendingRequestData> retrieveAllPendingUsers(
@QueryParam("status") final String status,
@QueryParam("username") final String username,
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate,
@QueryParam("offset") final Integer offset,
@QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.userPendingReadPlatformService.retrieveAllPendingRequests(status, username, fromDate, toDate, offset, limit);
}
@GET
@Path("pending/{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a pending user request by ID")
public String retrievePendingUser(@PathParam("id") final Long id, @Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final org.apache.fineract.useradministration.data.UserPendingRequestData data = this.userPendingReadPlatformService.retrievePendingRequest(id);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingRequestApiJsonSerializer.serialize(settings, data);
}
@POST
@Path("pending/{id}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending User creation or update")
public String approvePendingUser(@PathParam("id") final Long id) {
final CommandProcessingResult result = this.userPendingWritePlatformService.approvePendingRequest(id);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("pending/{id}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending User creation or update")
public String rejectPendingUser(@PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(id, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.rejectPendingRequest(id, command);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{userId}/pwd")
@Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.")
@@ -0,0 +1,13 @@
package org.apache.fineract.useradministration.data;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppUserPreferenceData {
private String name;
private String value;
}
@@ -0,0 +1,59 @@
/**
* 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.useradministration.data;
import java.io.Serializable;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class UserPendingRequestData implements Serializable {
private static final long serialVersionUID = 1L;
private final Long id;
private final Long userId;
private final String requestType;
private final String status;
private final String username;
private final String firstname;
private final String lastname;
private final String email;
private final String mobileNo;
private final Long officeId;
private final String officeName;
private final Long staffId;
private final String staffName;
private final String otpDeliveryMethod;
private final Boolean passwordNeverExpires;
private final Collection<String> roleNames;
private final Long makerId;
private final String makerUsername;
private final OffsetDateTime submittedDate;
private final Long checkerId;
private final String checkerUsername;
private final OffsetDateTime checkedDate;
private final String rejectReason;
private final Map<String, Object> originalValues;
private final Map<String, Object> proposedValues;
}
@@ -0,0 +1,54 @@
package org.apache.fineract.useradministration.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_appuser_preference")
public class AppUserPreference extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "value", nullable = false)
private String value;
protected AppUserPreference() {
//
}
private AppUserPreference(final AppUser appUser, final String name, final String value) {
this.appUser = appUser;
this.name = name;
this.value = value;
}
public static AppUserPreference create(final AppUser appUser, final String name, final String value) {
return new AppUserPreference(appUser, name, value);
}
public void updateValue(final String newValue) {
this.value = newValue;
}
public String getName() {
return this.name;
}
public String getValue() {
return this.value;
}
public AppUser getAppUser() {
return this.appUser;
}
}
@@ -0,0 +1,10 @@
package org.apache.fineract.useradministration.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AppUserPreferenceRepository extends JpaRepository<AppUserPreference, Long> {
Optional<AppUserPreference> findByAppUserIdAndName(Long appUserId, String name);
}
@@ -0,0 +1,113 @@
/**
* 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.useradministration.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_user_pending_request")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class UserPendingRequest extends AbstractPersistableCustom<Long> {
@Column(name = "user_id")
private Long userId;
@Column(name = "request_type", nullable = false, length = 20)
private String requestType;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "payload_json", nullable = false)
private String payloadJson;
@Column(name = "original_json")
private String originalJson;
@Column(name = "password_hash")
private String passwordHash;
@Column(name = "maker_id", nullable = false)
private Long makerId;
@Column(name = "submitted_date", nullable = false)
private OffsetDateTime submittedDate;
@Column(name = "checker_id")
private Long checkerId;
@Column(name = "checked_date")
private OffsetDateTime checkedDate;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
@Column(name = "pending_lock_key", length = 100, unique = true)
private String pendingLockKey;
public static UserPendingRequest create(final UserPendingRequestType requestType, final Long userId,
final String payloadJson, final String originalJson, final String passwordHash,
final Long makerId, final String pendingLockKey) {
final UserPendingRequest request = new UserPendingRequest();
request.requestType = requestType.name();
request.userId = userId;
request.status = UserPendingRequestStatus.PENDING.name();
request.payloadJson = payloadJson;
request.originalJson = originalJson;
request.passwordHash = passwordHash;
request.makerId = makerId;
request.submittedDate = OffsetDateTime.now(ZoneOffset.UTC);
request.pendingLockKey = pendingLockKey;
return request;
}
public boolean isPending() {
return UserPendingRequestStatus.PENDING.name().equals(this.status);
}
public boolean isMadeBy(final Long appUserId) {
return this.makerId != null && this.makerId.equals(appUserId);
}
public void approve(final Long checkerId) {
this.status = UserPendingRequestStatus.APPROVED.name();
this.checkerId = checkerId;
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
this.pendingLockKey = null;
}
public void reject(final Long checkerId, final String reason) {
this.status = UserPendingRequestStatus.REJECTED.name();
this.checkerId = checkerId;
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,30 @@
/**
* 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.useradministration.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface UserPendingRequestRepository extends JpaRepository<UserPendingRequest, Long>, JpaSpecificationExecutor<UserPendingRequest> {
Optional<UserPendingRequest> findByPendingLockKey(String pendingLockKey);
boolean existsByPendingLockKey(String pendingLockKey);
}
@@ -0,0 +1,49 @@
/**
* 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.useradministration.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.exception.UserPendingRequestNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserPendingRequestRepositoryWrapper {
private final UserPendingRequestRepository repository;
@Transactional(readOnly = true)
public UserPendingRequest findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new UserPendingRequestNotFoundException(id));
}
public UserPendingRequest saveAndFlush(final UserPendingRequest request) {
return this.repository.saveAndFlush(request);
}
public UserPendingRequest save(final UserPendingRequest request) {
return this.repository.save(request);
}
@Transactional(readOnly = true)
public boolean existsByPendingLockKey(final String pendingLockKey) {
return this.repository.existsByPendingLockKey(pendingLockKey);
}
}
@@ -0,0 +1,37 @@
/**
* 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.useradministration.domain;
public enum UserPendingRequestStatus {
PENDING,
APPROVED,
REJECTED;
public static UserPendingRequestStatus fromString(final String status) {
if (status == null) {
return null;
}
for (final UserPendingRequestStatus requestStatus : values()) {
if (requestStatus.name().equalsIgnoreCase(status)) {
return requestStatus;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status);
}
}
@@ -0,0 +1,36 @@
/**
* 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.useradministration.domain;
public enum UserPendingRequestType {
CREATE_USER,
UPDATE_USER;
public static UserPendingRequestType fromString(final String type) {
if (type == null) {
return null;
}
for (final UserPendingRequestType requestType : values()) {
if (requestType.name().equalsIgnoreCase(type)) {
return requestType;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type);
}
}
@@ -0,0 +1,28 @@
/**
* 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.useradministration.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserPendingRequestNotFoundException(final Long id) {
super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,5 @@
package org.apache.fineract.useradministration.service;
public interface AppUserPreferenceReadPlatformService {
String getPreferenceValue(Long appUserId, String name);
}
@@ -0,0 +1,20 @@
package org.apache.fineract.useradministration.service;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.domain.AppUserPreference;
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AppUserPreferenceReadPlatformServiceImpl implements AppUserPreferenceReadPlatformService {
private final AppUserPreferenceRepository appUserPreferenceRepository;
@Override
public String getPreferenceValue(Long appUserId, String name) {
Optional<AppUserPreference> pref = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name);
return pref.map(AppUserPreference::getValue).orElse(null);
}
}
@@ -0,0 +1,5 @@
package org.apache.fineract.useradministration.service;
public interface AppUserPreferenceWritePlatformService {
void updatePreference(Long appUserId, String name, String value);
}
@@ -0,0 +1,32 @@
package org.apache.fineract.useradministration.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserPreference;
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AppUserPreferenceWritePlatformServiceImpl implements AppUserPreferenceWritePlatformService {
private final AppUserPreferenceRepository appUserPreferenceRepository;
private final AppUserRepository appUserRepository;
@Transactional
@Override
public void updatePreference(Long appUserId, String name, String value) {
AppUserPreference preference = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name).orElse(null);
if (preference == null) {
AppUser appUser = appUserRepository.findById(appUserId)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.user.id.invalid", "User not found"));
preference = AppUserPreference.create(appUser, name, value);
} else {
preference.updateValue(value);
}
appUserPreferenceRepository.saveAndFlush(preference);
}
}
@@ -0,0 +1,30 @@
/**
* 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.useradministration.service;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
public interface UserPendingReadPlatformService {
Page<UserPendingRequestData> retrieveAllPendingRequests(String status, String username, String fromDate, String toDate,
Integer offset, Integer limit);
UserPendingRequestData retrievePendingRequest(Long id);
}
@@ -0,0 +1,329 @@
/**
* 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.useradministration.service;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
import org.apache.fineract.useradministration.domain.UserPendingRequest;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private static final String SELECT_SQL = """
SELECT r.id AS id,
r.user_id AS userId,
r.request_type AS requestType,
r.status AS status,
r.payload_json AS payloadJson,
r.original_json AS originalJson,
r.maker_id AS makerId,
maker.username AS makerUsername,
r.submitted_date AS submittedDate,
r.checker_id AS checkerId,
checker.username AS checkerUsername,
r.checked_date AS checkedDate,
r.reject_reason AS rejectReason
FROM m_user_pending_request r
JOIN m_appuser maker ON maker.id = r.maker_id
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
""";
@Override
@Transactional(readOnly = true)
public Page<UserPendingRequestData> retrieveAllPendingRequests(final String status, final String username,
final String fromDate, final String toDate, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" WHERE 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" AND r.status = ?");
args.add(status.trim().toUpperCase());
} else {
where.append(" AND r.status = 'PENDING'");
}
if (StringUtils.isNotBlank(fromDate)) {
where.append(" AND r.submitted_date::date >= ?::date");
args.add(fromDate);
}
if (StringUtils.isNotBlank(toDate)) {
where.append(" AND r.submitted_date::date <= ?::date");
args.add(toDate);
}
final String countSql = "SELECT COUNT(*) FROM m_user_pending_request r " + where;
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final int pageOffset = offset == null ? 0 : offset;
final int pageLimit = limit == null ? 20 : limit;
final String pageSql = SELECT_SQL + where + " ORDER BY r.submitted_date DESC LIMIT ? OFFSET ?";
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(pageLimit);
pageArgs.add(pageOffset);
final List<UserPendingRequestData> items = this.jdbcTemplate.query(pageSql, new UserPendingRowMapper(), pageArgs.toArray());
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
@Override
@Transactional(readOnly = true)
public UserPendingRequestData retrievePendingRequest(final Long id) {
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
final String sql = SELECT_SQL + " WHERE r.id = ?";
return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
}
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
@Override
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long userId = JdbcSupport.getLong(rs, "userId");
final String requestType = rs.getString("requestType");
final String status = rs.getString("status");
final String payloadJson = rs.getString("payloadJson");
final Long makerId = rs.getLong("makerId");
final String makerUsername = rs.getString("makerUsername");
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
final String checkerUsername = rs.getString("checkerUsername");
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final String rejectReason = rs.getString("rejectReason");
String username = null;
String firstname = null;
String lastname = null;
String email = null;
String mobileNo = null;
Long officeId = null;
String otpDeliveryMethod = null;
try {
if (payloadJson != null) {
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
if (payloadObj.has("username")) {
username = payloadObj.get("username").getAsString();
}
if (payloadObj.has("firstname")) {
firstname = payloadObj.get("firstname").getAsString();
}
if (payloadObj.has("lastname")) {
lastname = payloadObj.get("lastname").getAsString();
}
if (payloadObj.has("email")) {
email = payloadObj.get("email").getAsString();
}
if (payloadObj.has("mobileNo")) {
mobileNo = payloadObj.get("mobileNo").getAsString();
}
if (payloadObj.has("officeId")) {
officeId = payloadObj.get("officeId").getAsLong();
}
if (payloadObj.has("otpDeliveryMethod")) {
otpDeliveryMethod = payloadObj.get("otpDeliveryMethod").getAsString();
}
}
} catch (Exception ignored) {
}
return UserPendingRequestData.builder()
.id(id)
.userId(userId)
.requestType(requestType)
.status(status)
.username(username)
.firstname(firstname)
.lastname(lastname)
.email(email)
.mobileNo(mobileNo)
.officeId(officeId)
.otpDeliveryMethod(otpDeliveryMethod)
.makerId(makerId)
.makerUsername(makerUsername)
.submittedDate(submittedDate)
.checkerId(checkerId)
.checkerUsername(checkerUsername)
.checkedDate(checkedDate)
.rejectReason(rejectReason)
.build();
}
}
private static class UserPendingDetailRowMapper implements RowMapper<UserPendingRequestData> {
@Override
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long userId = JdbcSupport.getLong(rs, "userId");
final String requestType = rs.getString("requestType");
final String status = rs.getString("status");
final String payloadJson = rs.getString("payloadJson");
final String originalJson = rs.getString("originalJson");
final Long makerId = rs.getLong("makerId");
final String makerUsername = rs.getString("makerUsername");
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
final String checkerUsername = rs.getString("checkerUsername");
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final String rejectReason = rs.getString("rejectReason");
final Map<String, Object> proposedValues = new LinkedHashMap<>();
final Map<String, Object> originalValues = new LinkedHashMap<>();
String username = null;
String firstname = null;
String lastname = null;
String email = null;
String mobileNo = null;
Long officeId = null;
String otpDeliveryMethod = null;
Boolean passwordNeverExpires = null;
try {
if (payloadJson != null) {
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
for (final String key : payloadObj.keySet()) {
final JsonElement val = payloadObj.get(key);
if ("password".equals(key) || "repeatPassword".equals(key)) {
proposedValues.put("password", "Password will be set on approval");
} else if (val.isJsonPrimitive()) {
if (val.getAsJsonPrimitive().isBoolean()) {
proposedValues.put(key, val.getAsBoolean());
} else if (val.getAsJsonPrimitive().isNumber()) {
proposedValues.put(key, val.getAsNumber());
} else {
proposedValues.put(key, val.getAsString());
}
} else if (val.isJsonArray()) {
final List<String> list = new ArrayList<>();
for (final JsonElement elem : val.getAsJsonArray()) {
list.add(elem.getAsString());
}
proposedValues.put(key, list);
}
}
if (proposedValues.containsKey("username")) {
username = (String) proposedValues.get("username");
}
if (proposedValues.containsKey("firstname")) {
firstname = (String) proposedValues.get("firstname");
}
if (proposedValues.containsKey("lastname")) {
lastname = (String) proposedValues.get("lastname");
}
if (proposedValues.containsKey("email")) {
email = (String) proposedValues.get("email");
}
if (proposedValues.containsKey("mobileNo")) {
mobileNo = (String) proposedValues.get("mobileNo");
}
if (proposedValues.get("officeId") instanceof Number n) {
officeId = n.longValue();
}
if (proposedValues.containsKey("otpDeliveryMethod")) {
otpDeliveryMethod = (String) proposedValues.get("otpDeliveryMethod");
}
if (proposedValues.get("passwordNeverExpires") instanceof Boolean b) {
passwordNeverExpires = b;
}
}
if (originalJson != null) {
final JsonObject origObj = JsonParser.parseString(originalJson).getAsJsonObject();
for (final String key : origObj.keySet()) {
final JsonElement val = origObj.get(key);
if (val.isJsonPrimitive()) {
if (val.getAsJsonPrimitive().isBoolean()) {
originalValues.put(key, val.getAsBoolean());
} else if (val.getAsJsonPrimitive().isNumber()) {
originalValues.put(key, val.getAsNumber());
} else {
originalValues.put(key, val.getAsString());
}
} else if (val.isJsonArray()) {
final List<String> list = new ArrayList<>();
for (final JsonElement elem : val.getAsJsonArray()) {
list.add(elem.getAsString());
}
originalValues.put(key, list);
}
}
if (proposedValues.containsKey("password")) {
originalValues.put("password", "[PROTECTED]");
}
}
} catch (Exception ignored) {
}
return UserPendingRequestData.builder()
.id(id)
.userId(userId)
.requestType(requestType)
.status(status)
.username(username)
.firstname(firstname)
.lastname(lastname)
.email(email)
.mobileNo(mobileNo)
.officeId(officeId)
.otpDeliveryMethod(otpDeliveryMethod)
.passwordNeverExpires(passwordNeverExpires)
.makerId(makerId)
.makerUsername(makerUsername)
.submittedDate(submittedDate)
.checkerId(checkerId)
.checkerUsername(checkerUsername)
.checkedDate(checkedDate)
.rejectReason(rejectReason)
.originalValues(originalValues)
.proposedValues(proposedValues)
.build();
}
}
}
@@ -0,0 +1,33 @@
/**
* 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.useradministration.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface UserPendingWritePlatformService {
CommandProcessingResult submitCreateUser(JsonCommand command);
CommandProcessingResult submitUpdateUser(Long userId, JsonCommand command);
CommandProcessingResult approvePendingRequest(Long id);
CommandProcessingResult rejectPendingRequest(Long id, JsonCommand command);
}

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