Compare commits

...
25 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
keshav 1107610c08 add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 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 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
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
2026-08-11 11:34:54 +05:30
niteshandClaude Sonnet 5 2213308254 fix(shares): skip zero-value redeem transaction on account close
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 - 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-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 - 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 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
Closing a share account that already has zero approved shares no
longer synthesizes a redeem transaction, so it stops posting a
meaningless ₹0.00/₹0.00 GL journal-entry pair. Save/note-capture on
close now happen unconditionally instead of being gated on a
transaction existing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 20:10:57 +05:30
nitesh c568eb0abf feat(shares): close share account directly on withdrawal approval
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 - 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 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 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
Replaces the auto-submit-closure-request behavior with an immediate
account close, in the same @Transactional approval action, when a
redemption brings a share account's holding to zero — no separate
"Share Closure Request" maker-checker step is needed since the
redemption amount itself already went through this same review.
Removes ClerkShareClosureRequestApiResource entirely (the migration
for m_clerk_share_closure_request is left in place as a harmless,
now-unused table, to avoid a Liquibase checksum churn).

Also fixes a genuine Postgres-incompatibility bug this uncovered in
validateNoActiveLoanOrGuaranteeBeforeClosure(): `is_active = 1`
doesn't type-check against a boolean column on Postgres ("operator
does not exist: boolean = integer"), which was silently aborting the
whole approval transaction (harmlessly, since it's @Transactional —
verified no partial state was ever left behind). Fixed to `is_active
= true`, added the missing `type_enum = 1` (customer-type guarantors
only, matching the existing Guarantor Check elsewhere in this
workflow), and widened both this and the active-loan check to the
same loan_status_id set (300, 303, 304, 700) used there too.
2026-08-06 19:29:44 +05:30
nitesh 1426be9531 fix: correct changeset ids after renumbering merged AML/family-member migrations
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 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 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 E2E Tests / E2E Tests (Shard 1 of 10) (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 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
Follow-up to the previous merge commit — the internal Liquibase
changeSet ids in the 9 renumbered files (0447-0455) still referenced
their old 0438-0446 numbers; the rename staged the files before the id
update was applied. Ids now match filenames as intended.
2026-08-06 16:50:39 +05:30
nitesh 541c6ab062 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Resolved two conflicts:
- changelog-tenant.xml: both branches independently added Liquibase
  parts numbered 0438-0446 (dev: Share Withdrawal/Closure governance +
  KYC exception fields; demo: AML rule tiers/branch seeding/email
  tracking/family-member children table). Renumbered demo's incoming
  set to 0447-0455 (files renamed + internal changeSet ids updated to
  match) so both feature sets keep their own migrations intact.
- 0434_seed_aml_transaction_rules.xml: same seed data on both sides,
  differing only in the disambiguating SQL predicate. Kept dev's
  `AND aml_check_status IS NOT NULL` (deterministically targets the
  intended m_client_risk_rule row, per the file's own comment) over
  demo's `LIMIT 1` (arbitrary, could pick the wrong row).

Also dropped two JVM crash-dump log files that got staged by mistake.
2026-08-06 16:49:15 +05:30
niteshandClaude Sonnet 5 66a6a4766f feat(shares): add Share Account Closure maker-checker workflow
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 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 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
New ClerkShareClosureRequestApiResource mirrors the withdrawal resource:
clerk submits a closure request only once a share account holds zero
shares; a /validate endpoint re-checks shares-held, account status, any
active loan/guarantee, and client KYC/AML compliance; approve() executes
the real native "close" command.

ClerkShareWithdrawalRequestApiResource.approve() now also auto-submits a
PENDING closure request (via the same table) whenever a redemption brings
the account's share holding to zero, reusing the withdrawal's own maker
identity — a manager still has to separately Validate and Approve it, so
the closure request is created automatically but never executed without
review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:44:47 +05:30
Anshul Rathore b9aa29fb35 feat: commit latest changes on dev
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 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 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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (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 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
2026-08-06 10:46:22 +05:30
Anshul Rathore 84f8aa2274 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev 2026-08-03 12:51:55 +05:30
keshav 7773a9e9c6 feat(aml): add API endpoints and services for manual journal entry referrals
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 E2E Tests / E2E Tests (Shard 6 of 10) (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 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 - MariaDB / test (test-core-3) (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-2) (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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-03 10:49:37 +05:30
keshav c890faee78 form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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-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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
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 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (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 - MariaDB / test (test-core-1) (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 - 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
2026-08-02 11:45:54 +05:30
keshav abc7eb22a2 oss file updated, profitl loss as per the day month and also with date select
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 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 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 - 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 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 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 E2E Tests / E2E Tests (Shard 9 of 10) (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-01 11:54:30 +05:30
240 changed files with 14936 additions and 526 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ gitVersioning.apply {
}
ext['groovy.version'] = '4.0.17'
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
// Apply static weaving configuration to all subprojects
subprojects { subproject ->
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
+1 -1
View File
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
// Configure Java compilation
tasks.named('compileJava') {
outputs.cacheIf { true }
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
}
@@ -53,6 +53,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -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"), //
@@ -35,4 +35,13 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
@Param("since") LocalDateTime since);
long countByClientId(Long clientId);
/** Alerts for one customer inside the recurring-behaviour observation window. */
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
/**
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
}
@@ -0,0 +1,109 @@
/**
* 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
*
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
*/
@Entity
@Table(name = "m_aml_recurring_review")
public class AmlRecurringReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "alert_count_at_review", nullable = false)
private Integer alertCountAtReview;
@Column(name = "outcome", nullable = false, length = 30)
private String outcome;
@Column(name = "notes", nullable = false, length = 1000)
private String notes;
@ManyToOne(optional = false)
@JoinColumn(name = "reviewed_by", nullable = false)
private AppUser reviewedBy;
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
final AmlRecurringReview review = new AmlRecurringReview();
review.client = client;
review.alertCountAtReview = alertCountAtReview;
review.outcome = outcome.name();
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
return review;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
public Integer getAlertCountAtReview() {
return alertCountAtReview;
}
public AmlRecurringReviewOutcome getOutcome() {
return AmlRecurringReviewOutcome.valueOf(outcome);
}
public String getNotes() {
return notes;
}
public AppUser getReviewedBy() {
return reviewedBy;
}
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
}
@@ -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.portfolio.aml.domain;
/** What a reviewer concluded about a customer's repeated AML activity. */
public enum AmlRecurringReviewOutcome {
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
CLEARED,
/** Not conclusive; keep watching this customer more closely. */
MONITOR,
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
ESCALATED
}
@@ -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.portfolio.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
/** Newest first, so the caller can take the head as the latest review for a customer. */
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
}
@@ -52,6 +52,26 @@ public class AmlTransactionRule {
@Column(name = "threshold_amount", precision = 19, scale = 6)
private BigDecimal thresholdAmount;
/** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is
* not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */
@Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6)
private BigDecimal thresholdAmountMediumRisk;
@Column(name = "threshold_amount_high_risk", precision = 19, scale = 6)
private BigDecimal thresholdAmountHighRisk;
/**
* Declarative band the rule covers, shown on the AML Rules Master screen. Purely
* descriptive: unlike the tiered columns above these never take part in matching, so
* changing them cannot alter which transactions raise an alert. A null maximum means
* the rule is genuinely open-ended.
*/
@Column(name = "min_limit_amount", precision = 19, scale = 6)
private BigDecimal minLimitAmount;
@Column(name = "max_limit_amount", precision = 19, scale = 6)
private BigDecimal maxLimitAmount;
@Column(name = "rule_type", nullable = false, length = 20)
private String ruleType;
@@ -66,6 +86,29 @@ public class AmlTransactionRule {
protected AmlTransactionRule() {}
/**
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
* the two rows are always created together and the rule code is derived from it.
*/
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
final AmlTransactionRule rule = new AmlTransactionRule();
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
rule.description = description;
rule.appliesTo = appliesTo.name();
rule.ruleType = ruleType.name();
rule.windowDays = windowDays;
rule.thresholdAmount = thresholdAmount;
rule.minLimitAmount = minLimitAmount;
rule.maxLimitAmount = maxLimitAmount;
rule.active = true;
return rule;
}
public Long getId() {
return id;
}
@@ -86,6 +129,22 @@ public class AmlTransactionRule {
return thresholdAmount;
}
public BigDecimal getThresholdAmountMediumRisk() {
return thresholdAmountMediumRisk;
}
public BigDecimal getThresholdAmountHighRisk() {
return thresholdAmountHighRisk;
}
public BigDecimal getMinLimitAmount() {
return minLimitAmount;
}
public BigDecimal getMaxLimitAmount() {
return maxLimitAmount;
}
public AmlRuleType getRuleType() {
return AmlRuleType.valueOf(ruleType);
}
@@ -19,9 +19,17 @@
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
/**
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
}
@@ -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;
@@ -0,0 +1,35 @@
/**
* 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.client.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class ClientFamilyMemberChildData implements Serializable {
private final Long id;
private final String fullName;
private final Long genderId;
private final String gender;
private final LocalDate dateOfBirth;
}
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
private final LocalDate marriageDate;
private final String marriageAuthority;
@lombok.Setter
private java.util.List<ClientFamilyMemberChildData> children;
// template holder
private final Collection<CodeValueData> relationshipIdOptions;
private final Collection<CodeValueData> genderIdOptions;
@@ -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;
}
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "is_pwd")
private Boolean isPwd;
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
private ClientKyc clientKyc;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pwd_category_cv_id")
private CodeValue pwdCategory;
@@ -290,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,10 +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;
@@ -60,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;
@@ -103,6 +116,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "pan_status", length = 20)
private String panStatus;
// ── PAN declaration ──────────────────────────────────────────────────────
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
// never clear it however many times the officer retries. Rather than leave such a client
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
@Column(name = "pan_declaration_taken")
private Boolean panDeclarationTaken;
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
@Column(name = "pan_declaration_mismatch_fields", length = 100)
private String panDeclarationMismatchFields;
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
@Column(name = "pan_declaration_name_on_pan", length = 200)
private String panDeclarationNameOnPan;
/** Date of birth exactly as printed on the physical PAN card. */
@Column(name = "pan_declaration_dob_on_pan")
private LocalDate panDeclarationDobOnPan;
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
@Column(name = "pan_declaration_remarks", length = 500)
private String panDeclarationRemarks;
@Column(name = "pan_declaration_taken_on")
private LocalDate panDeclarationTakenOn;
// ── Aadhaar ──────────────────────────────────────────────────────────────
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
@Column(name = "aadhaar_number_masked", length = 12)
@@ -114,6 +157,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "aadhaar_verified_on")
private LocalDate aadhaarVerifiedOn;
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
// itself is issued there is no number to put through the OTP flow — the check cannot pass
// however long the branch waits. Recording the receipt is what waives the exception.
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
@Column(name = "aadhaar_receipt_taken")
private Boolean aadhaarReceiptTaken;
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
@Column(name = "aadhaar_enrolment_id", length = 40)
private String aadhaarEnrolmentId;
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
@Column(name = "aadhaar_enrolment_date")
private LocalDate aadhaarEnrolmentDate;
/** Officer's note on what was sighted. */
@Column(name = "aadhaar_receipt_remarks", length = 500)
private String aadhaarReceiptRemarks;
@Column(name = "aadhaar_receipt_taken_on")
private LocalDate aadhaarReceiptTakenOn;
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
@Column(name = "gstin_number", length = 15)
private String gstinNumber;
@@ -195,6 +263,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "freeze_authority", length = 100)
private String freezeAuthority;
@Column(name = "freeze_date")
private LocalDate freezeDate;
@Column(name = "freeze_amount", precision = 19, scale = 6)
private BigDecimal freezeAmount;
@Column(name = "unfreeze_date")
private LocalDate unfreezeDate;
@Column(name = "no_debit_date")
private LocalDate noDebitDate;
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
@Column(name = "form60_type", length = 20)
private String form60Type;
@@ -302,6 +382,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
private void applyExtendedFields(final JsonCommand command) {
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
this.enterprisePan = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("enterprisePan"), null);
this.cinNumber = StringUtils.defaultIfEmpty(
@@ -331,6 +429,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("freezeReason"), null);
this.freezeAuthority = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("freezeAuthority"), null);
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
this.form60Type = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form60Type"), null);
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
@@ -350,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);
}
@@ -362,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);
}
@@ -369,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);
}
@@ -376,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);
@@ -388,6 +497,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
v -> this.panAadhaarSeedingStatus = v);
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
v -> this.panDeclarationTaken = v != null ? v : false);
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
v -> this.panDeclarationMismatchFields = v);
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
v -> this.panDeclarationNameOnPan = v);
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
v -> this.panDeclarationDobOnPan = v);
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
v -> this.panDeclarationRemarks = v);
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
v -> this.panDeclarationTakenOn = v);
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
v -> this.aadhaarReceiptTaken = v != null ? v : false);
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
v -> this.aadhaarEnrolmentId = v);
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
v -> this.aadhaarEnrolmentDate = v);
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
v -> this.aadhaarReceiptRemarks = v);
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
v -> this.aadhaarReceiptTakenOn = v);
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
@@ -413,6 +544,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
v -> this.freezeAuthority = v);
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
@@ -435,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));
}
}
@@ -444,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);
}
}
@@ -454,11 +591,51 @@ 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);
}
}
}
private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current,
final Map<String, Object> changes, final java.util.function.Consumer<BigDecimal> setter) {
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();
@@ -659,6 +836,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return panStatus;
}
public Boolean getPanDeclarationTaken() {
return panDeclarationTaken;
}
/** Null-safe form for callers deciding whether the PAN exception is waived. */
public boolean isPanDeclarationTaken() {
return Boolean.TRUE.equals(this.panDeclarationTaken);
}
public String getPanDeclarationMismatchFields() {
return panDeclarationMismatchFields;
}
public String getPanDeclarationNameOnPan() {
return panDeclarationNameOnPan;
}
public LocalDate getPanDeclarationDobOnPan() {
return panDeclarationDobOnPan;
}
public String getPanDeclarationRemarks() {
return panDeclarationRemarks;
}
public LocalDate getPanDeclarationTakenOn() {
return panDeclarationTakenOn;
}
public Boolean getAadhaarReceiptTaken() {
return aadhaarReceiptTaken;
}
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
public boolean isAadhaarReceiptTaken() {
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
}
public String getAadhaarEnrolmentId() {
return aadhaarEnrolmentId;
}
public LocalDate getAadhaarEnrolmentDate() {
return aadhaarEnrolmentDate;
}
public String getAadhaarReceiptRemarks() {
return aadhaarReceiptRemarks;
}
public LocalDate getAadhaarReceiptTakenOn() {
return aadhaarReceiptTakenOn;
}
public String getAadhaarNumberMasked() {
return aadhaarNumberMasked;
}
@@ -906,4 +1137,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public Long getVersion() {
return this.version;
}
public LocalDate getFreezeDate() {
return this.freezeDate;
}
public BigDecimal getFreezeAmount() {
return this.freezeAmount;
}
public LocalDate getUnfreezeDate() {
return this.unfreezeDate;
}
public LocalDate getNoDebitDate() {
return this.noDebitDate;
}
}
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
void removeSignatureId(@Param("clientId") Long clientId);
/**
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
*/
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
long countByCifNumberPrefix(@Param("prefix") String prefix);
// ── Pre-save uniqueness probes ───────────────────────────────────────────
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
@@ -95,6 +95,44 @@ public class ClientRiskRule {
protected ClientRiskRule() {}
/**
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
* are attached separately by the caller, which owns the CodeValue lookup.
*/
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
final String dormantFlag) {
final ClientRiskRule rule = new ClientRiskRule();
rule.ruleName = ruleName;
rule.reason = reason;
rule.riskCategory = riskCategory;
rule.orderPosition = orderPosition;
rule.legalFormId = legalFormId;
rule.minMonthlyIncome = minMonthlyIncome;
rule.maxMonthlyIncome = maxMonthlyIncome;
rule.minAnnualIncome = minAnnualIncome;
rule.maxAnnualIncome = maxAnnualIncome;
rule.minAnnualTurnover = minAnnualTurnover;
rule.maxAnnualTurnover = maxAnnualTurnover;
rule.amlCheckStatus = amlCheckStatus;
rule.bannedEntityMatch = bannedEntityMatch;
rule.willfulDefaulter = willfulDefaulter;
rule.ecgcSalFlag = ecgcSalFlag;
rule.dormantFlag = dormantFlag;
return rule;
}
public void setOccupationCategory(final CodeValue occupationCategory) {
this.occupationCategory = occupationCategory;
}
public void setOccupation(final CodeValue occupation) {
this.occupation = occupation;
}
public Long getId() {
return id;
}
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
List<ClientRiskRule> findAllOrderedByOrderPosition();
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
Integer findMaxOrderPosition();
boolean existsByRuleNameIgnoreCase(String ruleName);
}
@@ -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);
}
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -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()));
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
@Service
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
private static final int MAX_CAUSE_DEPTH = 10;
@Deprecated
private final List<EntityImageIdAdapter> imageIdAdapters;
private final ContentStoreService storeService;
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) {
if (isMissingFromContentStore(e)) {
// The m_image row survives but the file it points at does not — the usual cause is a
// database copied between environments without its file store. For the caller that is
// simply "this entity has no image", not a server fault, so report it the same way as a
// missing image record instead of letting it surface as a 500.
log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType,
entityId);
throw new DocumentNotFoundException(entityType, entityId, -1L);
}
throw new DocumentInvalidRequestException(e);
}
}
/**
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
* way up (the file store rethrows as ContentStoreException).
*/
private boolean isMissingFromContentStore(final Throwable failure) {
Throwable cause = failure;
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
return true;
}
cause = cause.getCause();
}
return false;
}
}
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -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(
@@ -154,6 +154,11 @@ public class SecurityConfig {
// a Fineract Bearer token; security is enforced at the JWT envelope
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
// Client edit approval email open-tracking pixel — hit by the client's email client
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
// path is the only capability credential; the endpoint always returns a GIF so
// token validity is not leaked via different HTTP responses.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
// packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
@@ -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.");
}
}
}
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/alerts")
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
private final PlatformSecurityContext context;
private final AmlAlertReadPlatformService readPlatformService;
private final AmlAlertWritePlatformService writePlatformService;
private final JournalEntryReferralService referralService;
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
.orElse(null);
return this.toApiJsonSerializer.serialize(updated);
}
@GET
@Path("manual-referrals")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List ledger entries marked by a user",
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
public String retrieveManualReferrals() {
this.context.authenticatedUser();
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
}
@POST
@Path("manual-referral")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Mark a ledger entry cleared or suspicious",
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
public String referJournalEntry(final ManualReferralRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
request.getNotes());
return this.referralJsonSerializer.serialize(referral);
}
}
@@ -0,0 +1,82 @@
/**
* 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.aml.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.DefaultValue;
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.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/recurring-alerts")
@Component
@Tag(name = "AML Recurring Alerts",
description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.")
@RequiredArgsConstructor
public class AmlRecurringAlertApiResource {
private final PlatformSecurityContext context;
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final AmlRecurringAlertWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<AmlRecurringAlertData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the recurring-alert review queue",
description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. "
+ "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.")
public String retrieveRecurringAlerts(
@QueryParam("minAlerts") @DefaultValue("3") final int minAlerts,
@QueryParam("windowDays") @DefaultValue("90") final int windowDays,
@QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) {
this.context.authenticatedUser();
final List<AmlRecurringAlertData> rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1),
onlyNeedingReview);
return this.toApiJsonSerializer.serialize(rows);
}
@POST
@Path("{clientId}/review")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a review of a customer's repeated AML activity",
description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert "
+ "count grows beyond the count reviewed here.")
public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(),
request.getWindowDays() == null ? 0 : request.getWindowDays());
return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}";
}
}
@@ -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.aml.api;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */
@Getter
@Setter
@NoArgsConstructor
public class AmlRecurringReviewRequest {
@Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR")
private String outcome;
@Schema(description = "Why the customer keeps surfacing and what was decided",
requiredMode = Schema.RequiredMode.REQUIRED)
private String notes;
@Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it",
example = "90")
private Integer windowDays;
}
@@ -0,0 +1,39 @@
/**
* 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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
*
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
* no report) or ESCALATE (raises an STR case for the Principal Officer).
*/
@Getter
@Setter
@NoArgsConstructor
public class ManualReferralRequest {
private Long journalEntryId;
private String disposition;
private String notes;
}
@@ -22,7 +22,14 @@ import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* @param riskCategory
* severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating
* {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog
* ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity.
*/
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable {
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
implements Serializable {
}
@@ -0,0 +1,52 @@
/**
* 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.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* One customer who has tripped the AML engine repeatedly inside the observation window.
*
* @param alertCount
* alerts in the window — what the threshold is compared against
* @param highRiskCount
* how many of those came from a High severity catalog rule
* @param distinctRules
* number of different rules involved; one rule firing five times is a weaker signal than
* five different rules firing once, and the reviewer needs to see which it is
* @param openCount
* alerts still awaiting a per-transaction disposition
* @param escalatedCount
* alerts already escalated to an STR case
* @param needsReview
* true when there is no review yet, or new alerts have arrived since the last one
* @param alerts
* the triggering transactions themselves, so the reviewer can see why this customer keeps
* surfacing without leaving the row
*/
public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory,
int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount,
LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List<String> ruleCodes, boolean needsReview,
Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn,
String lastReviewNotes, LocalDate windowFrom, List<AmlAlertData> alerts) implements Serializable {
}
@@ -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.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
*
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
*/
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
@@ -19,12 +19,15 @@
package org.apache.fineract.portfolio.aml.service;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
private final AmlAlertRepository repository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
.collect(Collectors.toList());
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
// lookup inside the map step would re-read the same catalog rows repeatedly.
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
}
private AmlAlertData mapToData(AmlAlert alert) {
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
.toList();
final Map<Long, Integer> byId = new HashMap<>();
if (sourceIds.isEmpty()) {
return byId;
}
this.clientRiskRuleRepository.findAllById(sourceIds).forEach(rule -> byId.put(rule.getId(), rule.getRiskCategory()));
return byId;
}
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
}
}
@@ -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.portfolio.aml.service;
import org.apache.fineract.portfolio.client.domain.Client;
public interface AmlRecurringAlertNotificationService {
/**
* Called once per transaction after the engine has saved its alerts. Raises a notification for
* AML reviewers when this customer's alert count crosses the recurring-behaviour threshold.
*
* @param alertsJustRaised
* how many alerts this transaction produced, so the count before it can be derived
*/
void onAlertsRaised(Client client, int alertsJustRaised);
}
@@ -0,0 +1,87 @@
/**
* 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.aml.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.notification.service.UserNotificationService;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.springframework.stereotype.Service;
/**
* Pushes a notification to AML reviewers when one customer keeps tripping the engine.
*
* The review queue on the AML Rules Master screen is the durable record; this is the nudge that
* stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the
* customer's office, resolved by {@link UserNotificationService}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService {
/**
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already
* treats as "this customer has done this enough times to warrant another look".
*/
private static final int RECURRING_THRESHOLD = 3;
private static final int WINDOW_DAYS = 90;
private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT";
private static final String OBJECT_TYPE = "AML_RECURRING";
private static final String EVENT_TYPE = "recurringAmlActivity";
private final AmlAlertRepository alertRepository;
private final UserNotificationService userNotificationService;
@Override
public void onAlertsRaised(final Client client, final int alertsJustRaised) {
if (client == null || alertsJustRaised <= 0) {
return;
}
try {
final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay();
final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since);
final long before = after - alertsJustRaised;
// Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every
// alert once the threshold is passed. Notifying every time would bury the reviewer in
// duplicates for a customer they have already been told about; notifying only on the
// first crossing would go permanently silent on a customer who keeps offending.
if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) {
return;
}
final String content = String.format(
"%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.",
client.getDisplayName(), after, WINDOW_DAYS);
this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null,
client.getOffice() == null ? null : client.getOffice().getId());
log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after);
} catch (final RuntimeException e) {
// Consistent with AmlDomainServiceImpl: AML observation must never break the transaction
// it is watching, and a notification is the least important part of that observation.
log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e);
}
}
}
@@ -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.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
public interface AmlRecurringAlertReadPlatformService {
/**
* Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}.
*
* @param onlyNeedingReview
* when true, hides customers already reviewed at their current alert count
*/
List<AmlRecurringAlertData> retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview);
/** Alerts in the window for one customer, used to record a review against the count seen. */
int alertCountInWindow(Long clientId, int windowDays);
}
@@ -0,0 +1,134 @@
/**
* 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.aml.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Builds the recurring-alert review queue.
*
* The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so
* the rule-severity resolution lives in exactly one place and a customer's High-risk count here can
* never disagree with the Risk Category shown on the alert itself.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService {
private static final int HIGH_RISK = 3;
private final AmlAlertReadPlatformService alertReadPlatformService;
private final AmlRecurringReviewRepository reviewRepository;
private final JdbcTemplate jdbcTemplate;
@Override
public List<AmlRecurringAlertData> retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) {
final LocalDate windowFrom = LocalDate.now().minusDays(windowDays);
final LocalDateTime cutoff = windowFrom.atStartOfDay();
// Grouped in insertion order of the (already newest-first) alert list, so each customer's
// nested alerts stay newest-first without a second sort.
final Map<Long, List<AmlAlertData>> byClient = this.alertReadPlatformService.retrieve(null).stream()
.filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff))
.collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList()));
final List<Long> qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts)
.map(Map.Entry::getKey).toList();
if (qualifying.isEmpty()) {
return List.of();
}
final Map<Long, ClientSummary> summaries = clientSummaries(qualifying);
final List<AmlRecurringAlertData> rows = new ArrayList<>();
for (final Long clientId : qualifying) {
final List<AmlAlertData> alerts = byClient.get(clientId);
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
.orElse(null);
// Reviewed at a lower count means new alerts have landed since — the customer has to
// come back, otherwise one early review would silence them permanently.
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
if (onlyNeedingReview && !needsReview) {
continue;
}
final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null));
rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(),
summary.riskCategory(), alerts.size(),
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null),
alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview,
lastReview == null ? null : lastReview.getAlertCountAtReview(),
lastReview == null ? null : lastReview.getOutcome().name(),
lastReview == null ? null : lastReview.getReviewedBy().getUsername(),
lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(),
windowFrom, alerts));
}
// Worst offenders first; most recent activity breaks ties.
rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed()
.thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder())));
return rows;
}
@Override
public int alertCountInWindow(final Long clientId, final int windowDays) {
final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay();
return (int) this.alertReadPlatformService.retrieve(null).stream()
.filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count();
}
/** Account number and current KYC risk rating, fetched once for the whole queue. */
private Map<Long, ClientSummary> clientSummaries(final List<Long> clientIds) {
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory "
+ "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")";
final Map<Long, ClientSummary> byId = new HashMap<>();
this.jdbcTemplate.query(sql, rs -> {
byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"),
rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory")));
}, clientIds.toArray());
return byId;
}
private record ClientSummary(String accountNo, Integer riskCategory) {}
}
@@ -0,0 +1,32 @@
/**
* 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.aml.service;
public interface AmlRecurringAlertWritePlatformService {
/**
* Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count
* the reviewer was looking at so the customer only resurfaces when that count grows.
*
* @param windowDays
* observation window the reviewer had on screen, so the recorded count matches it
* @return the id of the stored review
*/
Long recordReview(Long clientId, String outcome, String notes, int windowDays);
}
@@ -0,0 +1,91 @@
/**
* 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.aml.service;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
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.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService {
private static final String RESOURCE = "amlrecurringreview";
private static final int DEFAULT_WINDOW_DAYS = 90;
private final AmlRecurringReviewRepository reviewRepository;
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
@Override
@Transactional
public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) {
final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes);
final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS;
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
// Counted here rather than taken from the request: a caller-supplied count could be used to
// silence a customer at an inflated number they were never actually reviewed at.
final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window);
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
this.context.authenticatedUser());
return this.reviewRepository.saveAndFlush(review).getId();
}
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
validator.reset().parameter("clientId").value(clientId).notNull();
validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000);
validator.reset().parameter("outcome").value(outcome).notBlank();
AmlRecurringReviewOutcome parsed = null;
if (StringUtils.isNotBlank(outcome)) {
for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) {
if (candidate.name().equalsIgnoreCase(outcome.trim())) {
parsed = candidate;
break;
}
}
if (parsed == null) {
validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported");
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
return parsed;
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -27,6 +28,8 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -35,8 +38,13 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
private static final int HIGH_RISK = 3;
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
@Override
public List<AmlAlert> evaluate(AmlTransactionContext context) {
@@ -52,24 +60,54 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
raised.add(alertRepository.save(alert));
}
}
// Every transaction type funnels through here, so the recurring-behaviour check is done once
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
// the batch size lets the service tell a first-time offender from a repeat one without
// re-querying what it just wrote.
if (!raised.isEmpty()) {
recurringAlertNotificationService.onAlertsRaised(context.getClient(), raised.size());
}
return raised;
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (rule.getThresholdAmount() == null) {
final BigDecimal threshold = resolveThreshold(rule, context);
if (threshold == null) {
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
// per rule and are not yet evaluated here - they exist in the catalog for future work.
return false;
}
switch (rule.getRuleType()) {
case THRESHOLD:
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
return context.getAmount().compareTo(threshold) >= 0;
case STRUCTURING:
case VELOCITY:
return context.getWindowAggregateAmount() != null
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
default:
return false;
}
}
/**
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
* clients.
*/
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
return rule.getThresholdAmount();
}
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
.orElse(null);
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
return rule.getThresholdAmountHighRisk();
}
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
return rule.getThresholdAmountMediumRisk();
}
return rule.getThresholdAmount();
}
}
@@ -0,0 +1,39 @@
/**
* 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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
/**
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
*
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
*/
public interface JournalEntryReferralService {
/** disposition: WHITELIST | ESCALATE. */
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
List<ManualReferralData> retrieveAll();
}
@@ -0,0 +1,224 @@
/**
* 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.aml.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
private final JdbcTemplate jdbcTemplate;
private final AmlAlertRepository alertRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlTransactionRuleRepository ruleRepository;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
/**
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
* posted against.
*
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
* a savings-type entity may be either a savings or a current account, which live in separate tables.
*
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
je.savings_transaction_id AS savingsTxnId,
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
l.client_id,
ct.client_id
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
LEFT JOIN m_loan l ON l.id = lt.loan_id
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
WHERE je.id = ?
""";
private static final String LIST_SQL = """
SELECT a.id AS alertId,
a.transaction_id AS journalEntryId,
a.client_id AS clientId,
c.display_name AS clientDisplayName,
a.amount AS amount,
a.currency_code AS currencyCode,
a.raised_on AS raisedOn,
a.status AS status,
a.disposition AS disposition,
u.username AS reviewedByUsername,
a.review_notes AS reviewNotes,
s.id AS strCaseId
FROM m_aml_alert a
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
LEFT JOIN m_client c ON c.id = a.client_id
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
ORDER BY a.raised_on DESC
""";
@Override
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
if (journalEntryId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
"A journal entry must be given to refer.");
}
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
"Disposition must be WHITELIST or ESCALATE.", disposition);
}
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
// Marking the same entry twice would put two cases in front of the Principal Officer for
// one transaction, so the first mark stands until it is explicitly reworked.
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
"This ledger entry has already been marked.", journalEntryId);
}
final Map<String, Object> row = loadEntry(journalEntryId);
final Number clientIdValue = (Number) row.get("clientId");
if (clientIdValue == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
}
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
final BigDecimal amount = (BigDecimal) row.get("amount");
final String currencyCode = (String) row.get("currencyCode");
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
// savings or loan transaction id.
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
LocalDateTime.now());
alertRepository.save(alert);
Long strCaseId = null;
if (escalate) {
alert.escalate(context.authenticatedUser(), notes);
alertRepository.save(alert);
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
strCaseId = strCase.getId();
} else {
alert.whitelist(context.authenticatedUser(), notes);
alertRepository.save(alert);
}
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
alert.getDisposition() == null ? null : alert.getDisposition().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
strCaseId);
}
@Override
@Transactional(readOnly = true)
public List<ManualReferralData> retrieveAll() {
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
MANUAL_RULE_CODE);
}
private Map<String, Object> loadEntry(final Long journalEntryId) {
try {
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
} catch (final EmptyResultDataAccessException e) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
}
}
/**
* TRANSFER covers everything that is neither a savings nor a loan posting.
*
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
* id; falling straight through to the transaction columns would mislabel those as transfers.
*/
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
final Number entityType = (Number) row.get("entityType");
if (entityType != null) {
if (entityType.intValue() == 2) {
return AmlTransactionType.SAVINGS;
}
if (entityType.intValue() == 1) {
return AmlTransactionType.LOAN;
}
}
if (row.get("savingsTxnId") != null) {
return AmlTransactionType.SAVINGS;
}
if (row.get("loanTxnId") != null) {
return AmlTransactionType.LOAN;
}
return AmlTransactionType.TRANSFER;
}
}
@@ -634,6 +634,13 @@ public class ClerkShareWithdrawalRequestApiResource {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
}
// If this redemption brought the account's holding to zero, close the share account right
// here in the SAME approval action — no separate "Share Closure Request" maker-checker step.
// Safe to do inline (unlike a raw manual "Close" from the account screen) because the
// redemption amount itself already went through this same Maker-Checker review; there are no
// shares left for the native close command to force-redeem without oversight.
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
res.addProperty("status", "APPROVED");
@@ -646,9 +653,35 @@ public class ClerkShareWithdrawalRequestApiResource {
} else {
res.addProperty("settlementWarning", settlementNote);
}
if (accountClosed) {
res.addProperty("accountClosed", true);
}
return gson.toJson(res);
}
/**
* Closes the share account when this redemption has just brought its holding to zero — the
* account closes immediately on approval, in the same step as the withdrawal itself, rather than
* needing a separate closure request to be submitted and approved afterwards.
*/
private boolean closeShareAccountIfSharesZero(final Long shareAccountId, final String requestedDateStr) {
final Long remainingShares = jdbcTemplate.queryForObject("SELECT total_approved_shares FROM m_share_account WHERE id = ?",
Long.class, shareAccountId);
if (remainingShares == null || remainingShares != 0) {
return false;
}
final JsonObject closePayload = new JsonObject();
closePayload.addProperty("locale", "en");
closePayload.addProperty("dateFormat", "dd MMMM yyyy");
closePayload.addProperty("closedDate", formatForFineract(requestedDateStr));
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
.withJson(closePayload.toString()).build();
commandsSourceWritePlatformService.logCommandSource(closeCommand);
return true;
}
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
/**
@@ -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.");
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
@@ -31,7 +32,9 @@ 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 java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Path("/v1/clients")
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class ClientEditApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
* whether the token matched, to avoid leaking token validity via response differences. */
private static final byte[] TRANSPARENT_GIF = {
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
};
private final PlatformSecurityContext context;
private final ClientEditWritePlatformService clientEditWritePlatformService;
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final FromJsonHelper fromApiJsonHelper;
private final ApiRequestParameterHelper apiRequestParameterHelper;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Client details edit",
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
+ "The maker of the edit cannot also approve it — no exceptions.")
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Context final HttpServletRequest httpRequest) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to approve client edits");
}
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
// reachable by the client's email client without needing a configured server.url property.
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
: ":" + httpRequest.getServerPort());
// Tenant ID from the Fineract-specific header (same header the frontend sends).
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
tenantId != null ? tenantId : "default");
return this.toApiJsonSerializer.serialize(result);
}
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
return this.toApiJsonSerializer.serialize(result);
}
// ── Email preview (authenticated) ─────────────────────────────────────────
@GET
@Path("{clientId}/edit/{pendingEditId}/email-preview")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
if (preview == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
}
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
@GET
@Path("edit/pixel/{token}")
@Produces("image/gif")
@Operation(summary = "Email open tracking pixel",
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
+ "regardless of whether the token matched, to avoid leaking token validity.")
public Response trackEmailOpen(@PathParam("token") final String token) {
if (token != null && !token.isBlank()) {
try {
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
if (pendingEditOpt.isPresent()) {
final var pendingEdit = pendingEditOpt.get();
pendingEdit.markEmailOpened();
pendingEditRepository.saveAndFlush(pendingEdit);
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
}
} catch (final Exception e) {
// Never fail a pixel request — email clients don't handle errors well.
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
}
}
return Response.ok(TRANSPARENT_GIF, "image/gif")
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
.header("Pragma", "no-cache")
.build();
}
}
@@ -22,6 +22,7 @@ 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.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/kyc/risk-rules")
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
private final PlatformSecurityContext context;
private final KycRiskRulesReadPlatformService readPlatformService;
private final KycRiskRulesWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
@GET
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
return this.toApiJsonSerializer.serialize(rules);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Add a KYC/AML risk rule",
description = "Appends a rule to the catalog. Supplying any transaction band or observation window also "
+ "creates the matching live-transaction rule, linked back to the new catalog entry. Returns the "
+ "persisted rule in the same shape as the catalog GET.")
public String createRiskRule(final KycRiskRuleRequest request) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
return this.toApiJsonSerializer.serialize(rule);
}
}
@@ -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,
@@ -65,12 +66,13 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
nameVerifiedParamName, screeningLogIdParamName,
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
"oldMobileVerificationExceptionDocumentId"));
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
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
@@ -53,6 +53,22 @@ public final class ClientKycApiConstants {
// ── Extended KYC fields ──────────────────────────────────────────────────
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus";
// ── PAN declaration (accepted when the PAN check cannot pass) ─────────────
public static final String panDeclarationTakenParam = "panDeclarationTaken";
public static final String panDeclarationMismatchFieldsParam = "panDeclarationMismatchFields";
public static final String panDeclarationNameOnPanParam = "panDeclarationNameOnPan";
public static final String panDeclarationDobOnPanParam = "panDeclarationDobOnPan";
public static final String panDeclarationRemarksParam = "panDeclarationRemarks";
public static final String panDeclarationTakenOnParam = "panDeclarationTakenOn";
// ── Aadhaar enrolment receipt (accepted when Aadhaar is not yet issued) ───
public static final String aadhaarReceiptTakenParam = "aadhaarReceiptTaken";
public static final String aadhaarEnrolmentIdParam = "aadhaarEnrolmentId";
public static final String aadhaarEnrolmentDateParam = "aadhaarEnrolmentDate";
public static final String aadhaarReceiptRemarksParam = "aadhaarReceiptRemarks";
public static final String aadhaarReceiptTakenOnParam = "aadhaarReceiptTakenOn";
public static final String enterprisePanParam = "enterprisePan";
public static final String cinNumberParam = "cinNumber";
public static final String dinNumberParam = "dinNumber";
@@ -72,6 +88,10 @@ public final class ClientKycApiConstants {
public static final String accountStatusParam = "accountStatus";
public static final String freezeReasonParam = "freezeReason";
public static final String freezeAuthorityParam = "freezeAuthority";
public static final String freezeDateParam = "freezeDate";
public static final String freezeAmountParam = "freezeAmount";
public static final String unfreezeDateParam = "unfreezeDate";
public static final String noDebitDateParam = "noDebitDate";
public static final String form60TypeParam = "form60Type";
public static final String form60DateParam = "form60Date";
public static final String form15ApplicableParam = "form15Applicable";
@@ -90,9 +110,14 @@ public final class ClientKycApiConstants {
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
@@ -101,9 +126,14 @@ public final class ClientKycApiConstants {
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.data;
import java.time.LocalDate;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@@ -52,11 +53,29 @@ public final class ClientKycData {
private final String panCategory;
private final String panStatus;
// ── PAN declaration ───────────────────────────────────────────────────────
/** Signed customer declaration accepted in place of a passing PAN check. */
private final Boolean panDeclarationTaken;
/** "NAME", "DOB", "NAME,DOB", or "NONE" when PAN could not be checked at all. */
private final String panDeclarationMismatchFields;
private final String panDeclarationNameOnPan;
private final LocalDate panDeclarationDobOnPan;
private final String panDeclarationRemarks;
private final LocalDate panDeclarationTakenOn;
// ── Aadhaar ───────────────────────────────────────────────────────────────
private final String aadhaarNumberMasked;
private final Boolean aadhaarVerified;
private final LocalDate aadhaarVerifiedOn;
// ── Aadhaar enrolment receipt ─────────────────────────────────────────────
/** Enrolment slip accepted for a customer who has applied for Aadhaar but holds none yet. */
private final Boolean aadhaarReceiptTaken;
private final String aadhaarEnrolmentId;
private final LocalDate aadhaarEnrolmentDate;
private final String aadhaarReceiptRemarks;
private final LocalDate aadhaarReceiptTakenOn;
// ── GSTIN ─────────────────────────────────────────────────────────────────
private final String gstinNumber;
private final Boolean gstinVerified;
@@ -65,7 +84,14 @@ public final class ClientKycData {
// ── Enterprise / Non-Individual ──────────────────────────────────────────
private final String enterprisePan;
private final String cinNumber;
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;
@@ -84,6 +110,10 @@ public final class ClientKycData {
private final String accountStatus;
private final String freezeReason;
private final String freezeAuthority;
private final LocalDate freezeDate;
private final BigDecimal freezeAmount;
private final LocalDate unfreezeDate;
private final LocalDate noDebitDate;
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
private final String form60Type;
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.data;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Data;
/**
@@ -51,6 +52,45 @@ public class ClientKycRequest {
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
private String panAadhaarSeedingStatus;
@Schema(description = "True when a signed customer declaration has been accepted in place of a passing "
+ "PAN check. Suppresses the automatic 'PAN Card Verification Failed' KYC exception.", example = "true")
private Boolean panDeclarationTaken;
@Schema(description = "Which client details differ from the PAN card: NAME, DOB, NAME,DOB, or NONE when "
+ "PAN could not be checked at all", example = "NAME")
private String panDeclarationMismatchFields;
@Schema(description = "Name exactly as printed on the physical PAN card", example = "RAJESH KUMAR SHARMA")
private String panDeclarationNameOnPan;
@Schema(description = "Date of birth exactly as printed on the physical PAN card", example = "12 May 1988")
private String panDeclarationDobOnPan;
@Schema(description = "Officer's note on why the mismatch exists and what proof was sighted",
example = "Client's PAN carries expanded middle name; passport sighted.")
private String panDeclarationRemarks;
@Schema(description = "Date the declaration was accepted", example = "07 August 2026")
private String panDeclarationTakenOn;
@Schema(description = "True when the customer's Aadhaar enrolment slip has been sighted and recorded, for a "
+ "customer who has applied for Aadhaar but does not hold one yet. Suppresses the automatic "
+ "'Aadhaar Verification Failed' KYC exception.", example = "true")
private Boolean aadhaarReceiptTaken;
@Schema(description = "28-digit Enrolment ID (EID) printed on the UIDAI enrolment slip",
example = "1234/12345/12345 12/05/2026 11:30:15")
private String aadhaarEnrolmentId;
@Schema(description = "Enrolment date printed on the slip", example = "12 May 2026")
private String aadhaarEnrolmentDate;
@Schema(description = "Officer's note on what was sighted", example = "Original UIDAI enrolment slip sighted.")
private String aadhaarReceiptRemarks;
@Schema(description = "Date the receipt was accepted", example = "07 August 2026")
private String aadhaarReceiptTakenOn;
@Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
private String enterprisePan;
@@ -96,6 +136,18 @@ public class ClientKycRequest {
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
private String freezeAuthority;
@Schema(description = "Date of account freeze (null if not frozen)")
private String freezeDate;
@Schema(description = "Amount frozen (null if not frozen)")
private BigDecimal freezeAmount;
@Schema(description = "Date the account was unfrozen (null unless accountStatus is UNFREEZE)")
private String unfreezeDate;
@Schema(description = "Date the no-debit restriction takes effect (null unless accountStatus is NO_DEBIT)")
private String noDebitDate;
@Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
private String form60Type;
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
private final String checkerName;
private final OffsetDateTime checkedDate;
private final String rejectReason;
// ── Email tracking fields ─────────────────────────────────────────────────
/** Whether a confirmation email was successfully sent to the client after approval. */
private final Boolean emailSent;
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
private final OffsetDateTime emailSentAt;
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
private final OffsetDateTime emailOpenedAt;
// emailSubject and emailBody are deliberately NOT included in the list DTO —
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
}
@@ -46,6 +46,20 @@ public final class KycRiskRuleData implements Serializable {
private final String willfulDefaulter;
private final String ecgcSalFlag;
private final String dormantFlag;
/** Populated only for rules that also have a live-transaction-monitoring counterpart in
* {@code m_aml_transaction_rule} (linked via source_client_risk_rule_id) - distinct from the
* client-onboarding income/turnover fields above, which drive KYC-time risk categorization. */
private final BigDecimal txnThresholdAmount;
private final String txnRuleType;
private final Integer txnWindowDays;
/** Risk-category-tiered overrides of {@link #txnThresholdAmount}, populated only when the
* linked {@code m_aml_transaction_rule} row has tiering configured (e.g. RTGS monitoring). */
private final BigDecimal txnThresholdAmountMediumRisk;
private final BigDecimal txnThresholdAmountHighRisk;
/** Declarative band the rule covers, for display on the AML Rules Master screen. Never
* used in matching; a null maximum means the rule is genuinely open-ended. */
private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount;
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -55,7 +69,10 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
final Integer riskCategory, final String reason, final Integer orderPosition,
final String amlCheckStatus, final String bannedEntityMatch,
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag) {
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id;
this.ruleName = ruleName;
this.legalFormId = legalFormId;
@@ -77,6 +94,13 @@ public final class KycRiskRuleData implements Serializable {
this.willfulDefaulter = willfulDefaulter;
this.ecgcSalFlag = ecgcSalFlag;
this.dormantFlag = dormantFlag;
this.txnThresholdAmount = txnThresholdAmount;
this.txnRuleType = txnRuleType;
this.txnWindowDays = txnWindowDays;
this.txnThresholdAmountMediumRisk = txnThresholdAmountMediumRisk;
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
this.txnMinLimitAmount = txnMinLimitAmount;
this.txnMaxLimitAmount = txnMaxLimitAmount;
}
public Long getId() {
@@ -162,4 +186,32 @@ public final class KycRiskRuleData implements Serializable {
public String getDormantFlag() {
return dormantFlag;
}
public BigDecimal getTxnThresholdAmount() {
return txnThresholdAmount;
}
public String getTxnRuleType() {
return txnRuleType;
}
public Integer getTxnWindowDays() {
return txnWindowDays;
}
public BigDecimal getTxnThresholdAmountMediumRisk() {
return txnThresholdAmountMediumRisk;
}
public BigDecimal getTxnThresholdAmountHighRisk() {
return txnThresholdAmountHighRisk;
}
public BigDecimal getTxnMinLimitAmount() {
return txnMinLimitAmount;
}
public BigDecimal getTxnMaxLimitAmount() {
return txnMaxLimitAmount;
}
}
@@ -0,0 +1,112 @@
/**
* 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.client.data;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/kyc/risk-rules}.
*
* Mirrors the columns the AML Rules Master screen displays. The {@code txn*} fields describe the
* live-transaction counterpart of the rule; supplying any of them creates a companion row in
* {@code m_aml_transaction_rule} linked back to the new {@code m_client_risk_rule} row. Leave them
* all blank for a pure KYC-time profile rule (income / turnover / occupation driven).
*/
@Getter
@Setter
@NoArgsConstructor
public class KycRiskRuleRequest {
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
private String ruleName;
@Schema(description = "Reason / criteria text explaining why the rule exists",
requiredMode = Schema.RequiredMode.REQUIRED)
private String reason;
@Schema(description = "Risk category the rule assigns: 1 Low, 2 Medium, 3 High",
requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
private Integer riskCategory;
@Schema(description = "Position in the catalog. Omit to append after the last existing rule.")
private Integer orderPosition;
@Schema(description = "Legal form the rule is restricted to (1 Person, 2 Entity). Null applies to all.")
private Integer legalFormId;
@Schema(description = "Occupation category code value the rule is restricted to")
private Long occupationCategoryCvId;
@Schema(description = "Occupation code value the rule is restricted to")
private Long occupationCvId;
private BigDecimal minMonthlyIncome;
private BigDecimal maxMonthlyIncome;
private BigDecimal minAnnualIncome;
private BigDecimal maxAnnualIncome;
private BigDecimal minAnnualTurnover;
private BigDecimal maxAnnualTurnover;
@Schema(description = "AML check status the rule records", example = "FLAGGED")
private String amlCheckStatus;
@Schema(description = "Banned entity match flag", example = "NO")
private String bannedEntityMatch;
@Schema(description = "Willful defaulter flag", example = "NO")
private String willfulDefaulter;
@Schema(description = "ECGC/SAL flag", example = "NO")
private String ecgcSalFlag;
@Schema(description = "Dormancy flag", example = "ACTIVE")
private String dormantFlag;
@Schema(description = "Lower bound of the transaction band the rule covers (display only)")
private BigDecimal txnMinLimitAmount;
@Schema(description = "Upper bound of the transaction band. Null means the rule is open-ended.")
private BigDecimal txnMaxLimitAmount;
@Schema(description = "Amount at which the rule fires on a live transaction. Defaults to the "
+ "lower bound of the band when omitted.")
private BigDecimal txnThresholdAmount;
@Schema(description = "Observation window in days. Omit or 0 for per-transaction.", example = "30")
private Integer txnWindowDays;
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
private String txnRuleType;
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
private String txnAppliesTo;
/**
* True when the officer has described a live-transaction rule rather than a pure KYC profile
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
*/
public boolean hasTransactionRule() {
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
}
}
@@ -0,0 +1,96 @@
/**
* 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.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_family_member_children")
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "family_member_id", nullable = false)
private ClientFamilyMembers familyMember;
@Column(name = "full_name", nullable = false)
private String fullName;
@ManyToOne
@JoinColumn(name = "gender_cv_id", nullable = false)
private CodeValue gender;
@Column(name = "date_of_birth", nullable = true)
private LocalDate dateOfBirth;
protected ClientFamilyMemberChildren() {
// Default constructor
}
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
final LocalDate dateOfBirth) {
this.familyMember = familyMember;
this.fullName = fullName;
this.gender = gender;
this.dateOfBirth = dateOfBirth;
}
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
final LocalDate dateOfBirth) {
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
}
public ClientFamilyMembers getFamilyMember() {
return this.familyMember;
}
public void setFamilyMember(ClientFamilyMembers familyMember) {
this.familyMember = familyMember;
}
public String getFullName() {
return this.fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public CodeValue getGender() {
return this.gender;
}
public void setGender(CodeValue gender) {
this.gender = gender;
}
public LocalDate getDateOfBirth() {
return this.dateOfBirth;
}
public void setDateOfBirth(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
}
}
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
public LocalDateTime getCreatedAt() { return createdAt; }
public Long getModifiedBy() { return modifiedBy; }
public LocalDateTime getModifiedOn() { return modifiedOn; }
public java.util.List<ClientFamilyMemberChildren> getChildren() {
return this.children;
}
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
if (this.children == null) {
this.children = new java.util.ArrayList<>();
} else {
this.children.clear();
}
if (children != null) {
this.children.addAll(children);
}
}
}
@@ -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);
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "pending_lock_key", length = 60, unique = true)
private String pendingLockKey;
// ── Email tracking ────────────────────────────────────────────────────────
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
@Column(name = "email_tracking_token", length = 36, unique = true)
private String emailTrackingToken;
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
@Column(name = "email_sent", nullable = false)
private Boolean emailSent = Boolean.FALSE;
/** Timestamp of the successful send. Null when emailSent is false. */
@Column(name = "email_sent_at")
private OffsetDateTime emailSentAt;
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
* the 1×1 img tag. Null until opened. */
@Column(name = "email_opened_at")
private OffsetDateTime emailOpenedAt;
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
* without regenerating the template. */
@Column(name = "email_subject", length = 300)
private String emailSubject;
/** Exact HTML body that was sent — stored alongside email_subject. */
@Column(name = "email_body", columnDefinition = "TEXT")
private String emailBody;
/** Exact sender address configured at the time the email was dispatched. */
@Column(name = "email_from", length = 255)
private String emailFrom;
// ── Factory / lifecycle ───────────────────────────────────────────────────
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
final AppUser maker) {
final ClientPendingEdit edit = new ClientPendingEdit();
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
edit.makerId = maker.getId();
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
edit.pendingLockKey = lockKeyFor(client.getId());
edit.emailSent = Boolean.FALSE;
return edit;
}
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
this.rejectReason = reason;
this.pendingLockKey = null;
}
/** Called after a successful email send. Records the token (for pixel tracking), the exact
* subject and body, and marks the row as sent. */
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
this.emailTrackingToken = token;
this.emailSubject = subject;
this.emailBody = body;
this.emailFrom = fromAddress;
this.emailSent = Boolean.TRUE;
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
}
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
public void markEmailOpened() {
if (this.emailOpenedAt == null) {
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
}
}
}
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
}
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
}
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
return this.repository.findByEmailTrackingToken(token);
}
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
return this.repository.saveAndFlush(edit);
}
@@ -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")
@@ -76,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
public static final String CHILDREN = "children";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY));
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
public static final String RELATION_SHIP_ID = "relationShipId";
private final FromJsonHelper fromApiJsonHelper;
@@ -18,79 +18,42 @@
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
* <ul>
* <li><b>TT</b> — Customer type code:
* <code>11</code> Individual, <code>22</code> Corporate, <code>33</code> Minor, <code>44</code> Staff.
* Priority order when multiple flags overlap: <em>Staff &gt; Minor &gt; Corporate &gt; Individual</em>.</li>
* <li><b>YYYY</b> — Year the client was submitted (4 digits).</li>
* <li><b>NNNNNN</b> — Zero-padded 6-digit sequence within the (type, year) bucket.</li>
* </ul>
* Generates CIF numbers in the format <code>CIF</code> followed by the client's id
* zero-padded to 10 digits — e.g. client 123 → <code>CIF0000000123</code>.
*
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
* The generator handles concurrent inserts by retrying on uniqueness collisions
* (the caller's outer save is what actually triggers the unique constraint).
* <p>
* Uniqueness is structural rather than probabilistic: <code>m_client.id</code> is a single sequence
* shared by every office, so two clients booked concurrently at different branches can never derive
* the same CIF. That makes the number globally unique across the whole institution, not just within
* the branch that created it, and removes the read-then-write race a "count existing + 1" scheme has.
* The <code>cif_number_unique</code> index on m_client remains the backstop.
* </p>
*
* <p>
* The client must already be persisted when this is called, since the id is the source of the number.
* </p>
*/
@Component
@RequiredArgsConstructor
public class CifNumberGenerator {
private static final String TYPE_INDIVIDUAL = "11";
private static final String TYPE_CORPORATE = "22";
private static final String TYPE_MINOR = "33";
private static final String TYPE_STAFF = "44";
private static final String CIF_PREFIX = "CIF";
private static final int SEQUENCE_WIDTH = 6;
private static final int MAX_COLLISION_RETRIES = 5;
private final ClientRepository clientRepository;
/** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */
private static final int SEQUENCE_WIDTH = 10;
/**
* Generate the next CIF number for the given client. Caller is responsible for persisting.
* Generate the CIF number for the given client. Caller is responsible for persisting.
*/
public String generate(final Client client) {
final String typeCode = resolveTypeCode(client);
final int year = resolveYear(client);
final String prefix = typeCode + year;
long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1;
for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0');
if (clientRepository.countByCifNumberPrefix(candidate) == 0) {
return candidate;
}
nextSequence++;
final Long clientId = client.getId();
if (clientId == null) {
throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted");
}
// Last resort: append client id to guarantee uniqueness if every attempt collided
return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0');
}
private String resolveTypeCode(final Client client) {
if (client.isStaff()) {
return TYPE_STAFF;
}
if (Boolean.TRUE.equals(client.getIsMinor())) {
return TYPE_MINOR;
}
final Integer legalForm = client.getLegalForm();
if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) {
return TYPE_CORPORATE;
}
return TYPE_INDIVIDUAL;
}
private int resolveYear(final Client client) {
final LocalDate submittedOn = client.getSubmittedOnDate();
return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear();
return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0');
}
}
@@ -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.client.service;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
/**
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
* their pending profile-edit. Also records the tracking token, subject and body back onto the
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
* dialog can reproduce it later without regenerating the template.
*
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
* that a mail-server outage never rolls back an already-committed approval.
*/
public interface ClientEditEmailService {
/**
* Attempts to send a confirmation email to the client. On success, calls
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
* {@code pendingEdit} — the approval itself is never rolled back.
*
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
* yet persisted by the caller at this point)
* @param client the client whose details were edited; used for display name and email
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
* {@code https://cbs.example.com}) used to build the tracking-pixel href
* @param tenantId the current tenant identifier, used to build the pixel URL
*/
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
}
@@ -0,0 +1,301 @@
/**
* 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.client.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
* Checker. Records the tracking token, exact subject and exact body back onto the
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
* the entity after this method returns).
*
* <p>Template selection:
* <ul>
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
* {@code original_json}). Lists old→new values in a table.</li>
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
* </ul>
*
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
* approval transaction to roll back. The caller catches any exception from this service and logs it.
*/
@Service
@RequiredArgsConstructor
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
private static final String BANK_NAME = "CBSNexora";
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
/** Contact fields that trigger Template A when they actually change. */
private static final String FIELD_MOBILE = "mobileNo";
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
private static final String FIELD_EMAIL = "emailAddress";
/** Human-readable labels for every field that can appear in the email body — mirrors the
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
private static final Map<String, String> FIELD_LABELS;
static {
FIELD_LABELS = new LinkedHashMap<>();
FIELD_LABELS.put("firstname", "First Name");
FIELD_LABELS.put("middlename", "Middle Name");
FIELD_LABELS.put("lastname", "Last Name");
FIELD_LABELS.put("fullname", "Full Name");
FIELD_LABELS.put("mobileNo", "Mobile Number");
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
FIELD_LABELS.put("emailAddress", "Email Address");
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
FIELD_LABELS.put("genderId", "Gender");
FIELD_LABELS.put("maritalStatusId", "Marital Status");
FIELD_LABELS.put("educationLevelId", "Education Level");
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
FIELD_LABELS.put("occupationId", "Occupation");
FIELD_LABELS.put("religionId", "Religion");
FIELD_LABELS.put("socialCategoryId", "Social Category");
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
FIELD_LABELS.put("nationalityId", "Nationality");
FIELD_LABELS.put("officeId", "Branch / Office");
FIELD_LABELS.put("staffId", "Relationship Manager");
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
FIELD_LABELS.put("legalFormId", "Legal Form");
FIELD_LABELS.put("salutationId", "Salutation");
}
private final EmailSenderService emailSenderService;
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
@Override
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
final String baseUrl, final String tenantId) {
final String toEmail = client.getEmailAddress();
if (toEmail == null || toEmail.isBlank()) {
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
client.getId(), pendingEdit.getId());
return;
}
try {
final String token = UUID.randomUUID().toString();
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
final boolean isContactChange = detectContactChange(payload, original);
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
final String subject;
final String body;
if (isContactChange) {
subject = "Your contact details have been updated — " + BANK_NAME;
body = buildContactChangeBody(client.getDisplayName(), payload, original,
checkedDateFormatted, pixelUrl);
} else {
subject = "Your profile details have been updated — " + BANK_NAME;
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
checkedDateFormatted, pixelUrl);
}
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
if (sent) {
String fromEmail = "noreply@cbsnexora.in";
try {
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
fromEmail = smtp.getFromEmail();
}
} catch (Exception e) {
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
}
pendingEdit.markEmailSent(token, subject, body, fromEmail);
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
} else {
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
}
} catch (final Exception e) {
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
pendingEdit.getId(), e.getMessage(), e);
}
}
// ── Template detection ────────────────────────────────────────────────────
/**
* Returns true if any of the three contact fields actually changed between
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
* falls back to false (Template B).
*/
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
if (original == null) {
return false;
}
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
final String payloadVal = stringOf(payload, field);
final String originalVal = stringOf(original, field);
// Only counts as a change when both sides have a value and they differ, OR when
// payload has a non-blank value and original didn't have the key at all.
if (payloadVal != null && !payloadVal.equals(originalVal)) {
return true;
}
}
return false;
}
// ── Template A ────────────────────────────────────────────────────────────
private String buildContactChangeBody(final String clientName, final JsonObject payload,
final JsonObject original, final String date, final String pixelUrl) {
final StringBuilder rows = new StringBuilder();
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
final String newVal = stringOf(payload, field);
final String oldVal = original != null ? stringOf(original, field) : null;
if (newVal == null) {
continue;
}
if (oldVal != null && oldVal.equals(newVal)) {
continue;
}
final String label = FIELD_LABELS.getOrDefault(field, field);
rows.append("<tr>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
.append(escHtml(newVal)).append("</td>")
.append("</tr>");
}
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
+ "<p>Dear " + escHtml(clientName) + ",</p>"
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
+ "<thead><tr>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
+ "</body></html>";
}
// ── Template B ────────────────────────────────────────────────────────────
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
final JsonObject original, final String date, final String pixelUrl) {
final StringBuilder items = new StringBuilder();
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
final String key = entry.getKey();
// Skip internal/non-display keys
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
continue;
}
final String payloadVal = stringOf(payload, key);
final String originalVal = original != null ? stringOf(original, key) : null;
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
continue;
}
final String label = FIELD_LABELS.getOrDefault(key, key);
items.append("<li>").append(escHtml(label)).append("</li>");
}
final String listHtml = items.length() > 0
? "<ul style='padding-left:20px;'>" + items + "</ul>"
: "<p><em>No specific fields identified.</em></p>";
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
+ "<p>Dear " + escHtml(clientName) + ",</p>"
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
+ "<p>The following fields were changed:</p>"
+ listHtml
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
+ "</body></html>";
}
// ── Helpers ───────────────────────────────────────────────────────────────
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
// without requiring a tenant header (it accepts the token and commits in the default
// datasource context). However we embed it as a query param so future multi-tenant
// support is easy to add without changing the stored URL.
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
}
private JsonObject parseJson(final String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
final JsonElement el = JsonParser.parseString(json);
return el.isJsonObject() ? el.getAsJsonObject() : null;
} catch (final Exception e) {
return null;
}
}
private String stringOf(final JsonObject obj, final String key) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
return null;
}
final JsonElement el = obj.get(key);
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
}
private String escHtml(final String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;");
}
}
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
CommandProcessingResult approveEdit(Long clientId);
/**
* Approves the pending edit and, on success, attempts to send a confirmation email to the
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
* can embed an absolute tracking-pixel URL in the email body.
*
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
* @param tenantId current tenant identifier
*/
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
}
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final ClientWritePlatformService clientWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final ClientEditEmailService clientEditEmailService;
@Transactional
@Override
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long clientId) {
public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
// Replay the staged JSON through the SAME write-service method a direct PUT would have
// used, so every bit of Client's existing validation/change-detection logic runs exactly
// as it would for an immediate edit — just deferred until a different user approves it.
final Client client = pendingEdit.getClient();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
pendingEdit.approve(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
// Send confirmation email — silently swallowed on any failure so the approval is never
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
// email tracking columns. If the send fails or the client has no email, this save is
// a no-op (email_sent remains false).
try {
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
} else {
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
}
} catch (final Exception e) {
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
pendingEdit.getId(), e);
}
this.pendingEditRepository.saveAndFlush(pendingEdit);
return result;
}
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
for (ClientFamilyMembersData fm : familyMembers) {
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
}
return familyMembers;
}
@Override
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
if (fm != null) {
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
}
return fm;
}
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
"c.gender_cv_id as genderId, cv.code_value as gender " +
"from m_family_member_children c " +
"left join m_code_value cv on c.gender_cv_id = cv.id " +
"where c.family_member_id = ?";
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
.id(rs.getLong("id"))
.fullName(rs.getString("fullName"))
.genderId(JdbcSupport.getLong(rs, "genderId"))
.gender(rs.getString("gender"))
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
.build();
}, familyMemberId);
}
@Override
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
}
}
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
if (childrenArray != null) {
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
for (JsonElement childElement : childrenArray) {
if (childElement.isJsonNull()) continue;
JsonObject childObj = childElement.getAsJsonObject();
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
LocalDate dob = null;
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
try {
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
} catch (DateTimeParseException e) {
LOG.warn("Could not parse dateOfBirth for child", e);
}
}
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
}
}
}
familyMember.setChildren(children);
}
@Override
public void validateFamilyRequirementsForActivation(final Client client) {
if (client == null || client.getId() == null || client.getLegalForm() == null) {
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
}
if (command.parameterExists("children")) {
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
}
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
if (member.has("children") && !member.get("children").isJsonNull()) {
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
}
this.clientFamilyRepository.saveAndFlush(familyMember);
}
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
clientFamilyMember.getRelationship(), familyMemberId);
if (command.parameterExists("children")) {
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
}
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
@@ -85,15 +85,31 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
.panCategory(kyc.getPanCategory())
.panStatus(kyc.getPanStatus())
.panDeclarationTaken(kyc.getPanDeclarationTaken())
.panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields())
.panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan())
.panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan())
.panDeclarationRemarks(kyc.getPanDeclarationRemarks())
.panDeclarationTakenOn(kyc.getPanDeclarationTakenOn())
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
.aadhaarVerified(kyc.isAadhaarVerified())
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
.aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken())
.aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId())
.aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate())
.aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks())
.aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn())
.gstinNumber(kyc.getGstinNumber())
.gstinVerified(kyc.isGstinVerified())
.gstinVerifiedOn(kyc.getGstinVerifiedOn())
.enterprisePan(kyc.getEnterprisePan())
.cinNumber(kyc.getCinNumber())
.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())
@@ -106,6 +122,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.accountStatus(kyc.getAccountStatus())
.freezeReason(kyc.getFreezeReason())
.freezeAuthority(kyc.getFreezeAuthority())
.freezeDate(kyc.getFreezeDate())
.freezeAmount(kyc.getFreezeAmount())
.unfreezeDate(kyc.getUnfreezeDate())
.noDebitDate(kyc.getNoDebitDate())
.form60Type(kyc.getForm60Type())
.form60Date(kyc.getForm60Date())
.form15Applicable(kyc.getForm15Applicable())
@@ -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,45 +138,30 @@ 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()) {
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
final boolean panChanged = changes.containsKey("panNumber");
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
final boolean gstinChanged = changes.containsKey("gstinNumber");
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
if (panChanged && currentStatus.isVerified()) {
kyc.resetPanVerification();
changes.put("panVerified", false);
}
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
}
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
// Handles the common case of editing remarks / risk on a fully-verified KYC.
kyc.tryAutoVerify();
if (kyc.getKycStatus().isVerified()) {
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
}
kycRepositoryWrapper.saveAndFlush(kyc);
}
@@ -356,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.
@@ -364,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
@@ -542,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);
@@ -558,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);
}
@@ -722,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
@@ -997,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);
@@ -1009,23 +1033,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
return; // no-op — edit-client sent the same KYC values that are already on the row
}
// Same audit semantics as the explicit updateKyc endpoint:
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
final boolean panChanged = changes.containsKey("panNumber");
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
final boolean gstinChanged = changes.containsKey("gstinNumber");
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
if (currentStatus.isVerified() && panChanged) {
kyc.resetPanVerification();
}
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
}
kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.client.service;
import java.util.Map;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
*/
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
Integer limit);
/**
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
* edit — only populated once the approval email has been sent. Both {@code clientId} and
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
* when the record does not exist.
*/
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
}
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
pe.submitted_date AS submittedDate,
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
pe.email_opened_at AS emailOpenedAt
FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id
JOIN m_appuser maker ON maker.id = pe.maker_id
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
final boolean emailSent = rs.getBoolean("emailSent");
return ClientPendingEditData.builder() //
.id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) //
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
.checkerName(rs.getString("checkerName")) //
.checkedDate(checkedDate) //
.rejectReason(rs.getString("rejectReason")) //
.emailSent(emailSent) //
.emailSentAt(emailSentAt) //
.emailOpenedAt(emailOpenedAt) //
.build();
};
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
@Override
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
final String sql = """
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
c.email_address AS clientEmail
FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id
WHERE pe.id = ? AND pe.client_id = ?
""";
try {
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("emailSubject", rs.getString("emailSubject"));
result.put("emailBody", rs.getString("emailBody"));
result.put("emailSent", rs.getBoolean("emailSent"));
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
result.put("emailFrom", rs.getString("emailFrom"));
result.put("emailTo", rs.getString("clientEmail"));
return result;
}, pendingEditId, clientId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
}
@@ -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;
@@ -96,6 +97,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -133,9 +135,12 @@ 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;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -154,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() //
@@ -609,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)) {
@@ -617,9 +641,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientRepository.saveAndFlush(newClient);
}
// CIF Number generation — TTYYYYNNNNNN format. TT picked from client type
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate,
// NNNNNN is the next 6-digit sequence within the (type, year) bucket.
// CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123.
// Must run after the saveAndFlush above, since the id is what the number is derived from;
// deriving it from the shared id sequence is what keeps CIFs unique across all branches.
if (StringUtils.isBlank(cifNumber)) {
newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
this.clientRepository.saveAndFlush(newClient);
@@ -754,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
@@ -761,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);
}
@@ -788,13 +814,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
* client onboarded without a mobile number is having one set for the first time) — there is no
* existing verified channel to prove possession of in that case.
*/
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
final String newValue) {
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
if (StringUtils.isNotBlank(oldValue)) {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
final Integer docCount = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
Integer.class, oldMobileExceptionDocId, clientId);
if (docCount == null || docCount == 0) {
throw new PlatformDataIntegrityException(
"error.msg.client.mobile.old.verification.exception.invalid.document",
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
}
} else {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
}
}
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
@@ -862,24 +902,30 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String oldValue = clientForUpdate.getMobileNo();
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
changes.put(ClientApiConstants.mobileNoParamName, newValue);
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
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())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
final String oldValue = clientForUpdate.getEmailAddress();
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
changes.put(ClientApiConstants.emailAddressParamName, newValue);
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
oldEmailAddressForNotification = oldValue;
@@ -1450,7 +1496,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
validateParentGroupRulesBeforeClientActivation(client);
this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client);
// Mother/Father-or-Caretaker is enforced where family members are captured (the Family
// Details step), not here: a client can reach approval through several different forms,
// and gating activation on it left approvers unable to approve what was handed to them.
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
@@ -1682,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();
@@ -23,4 +23,7 @@ import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
public interface KycRiskRulesReadPlatformService {
List<KycRiskRuleData> retrieveAllRules();
/** One rule with its live-transaction counterpart resolved, as returned after a create. */
KycRiskRuleData retrieveRule(Long ruleId);
}

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