Compare commits

..
Author SHA1 Message Date
Sonali Likhitkar a1de1aab93 implementation of loan product creation
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (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 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 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-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
2026-09-01 11:13:33 +05:30
niteshandClaude Sonnet 5 eb1aa767f7 fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
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 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 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 (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-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 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 - 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
Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:04:23 +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
nitesh 1a9adaec24 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
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 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 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 - 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 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-05 18:34:33 +05:30
niteshandClaude Sonnet 5 2da5a25e04 feat(shares): add compliance and loan/guarantor/tenure checks to validation
The /validate endpoint now re-checks live state that matters for a real
UCB share redemption: Client KYC verified, Risk Category not High, no open
AML alerts, and — since this bank supports loan-against-shares via
m_share_account.linked_loan_id — a Loan Check (no active loan secured by
these shares), Tenure Check (linked loan's term completed), and Guarantor
Check (client not guaranteeing someone else's active loan). Dropped the
redundant "status is PENDING" check and replaced raw status_enum codes
with human-readable labels throughout. getById() also now returns a
client compliance summary so the review screen can show it without
running the popup validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:33:12 +05:30
keshav baf30cff68 kyc exception at client creation time and some tds work
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 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 - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract 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 - 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
2026-08-05 17:27:56 +05:30
niteshandClaude Sonnet 5 f1d68d17fe feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
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 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 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
New ClerkShareWithdrawalRequestApiResource backs a clerk-submits/manager-
approves flow for share redemption: on submit, validates and persists the
clerk-chosen settlement Payment Type (must be non-cash) plus a full audit
snapshot of the settlement row; on approve, executes the real "redeemshares"
command and, in the same transaction, credits the redemption value to the
share account's linked savings account using that stored payment type (with
a hardcoded-lookup fallback for pre-existing rows). Adds a /validate endpoint
that re-checks live account/GL state (not just the submit-time snapshot) so
a manager can verify before approving. Also exposes linked-savings-account
data on ShareAccount reads so the frontend can display/validate it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:56:50 +05:30
keshav d7d22382a5 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into uate
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 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 - 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 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 - 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
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-07-31 15:20:07 +05:30
niteshandClaude Sonnet 5 7cdc25322e fix(aml): resolve ambiguous order_position subquery breaking tenant migration/startup
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 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (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-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-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
0434_seed_aml_transaction_rules.xml resolved source_client_risk_rule_id via
`SELECT id FROM m_client_risk_rule WHERE order_position = N`, assuming
order_position was unique. It isn't: 0430_seed_aml_compliance_rules.xml seeds
its own 221 rows with order_position 1-235, independently of the older
0428_add_client_risk_rules.xml rows, and several values collide (e.g. both
insert a row at order_position 10 and 30). Every affected subquery returned
more than one row, aborting the whole tenant's Liquibase run and app startup.

aml_check_status is populated only on 0430's rows (0428's predate that
column and are NULL there), so pairing it with order_position uniquely
targets the intended row without touching either seed changeset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 14:27:08 +05:30
nitesh a4c3dbb6c4 Merge remote-tracking branch 'origin/dev' into uate
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (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 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 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 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 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-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
2026-07-31 13:58:55 +05:30
niteshandClaude Sonnet 5 dd8445de06 fix(accounting): resolve Sponsor DD Payable SBI GL account parent by gl_code, update default tenant DB password
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 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
The 0345 changelog hardcoded the "OTHER LIABILITY" parent row's numeric id
(22), which only matched the default tenant's auto-increment history and
broke the FK insert (and the whole Liquibase run / app startup) on tenants
seeded differently, e.g. saga. Now resolves parent_id via a SELECT on
gl_code='9' and marks the changeSet runOnChange so it safely re-validates
on tenants that already ran the old version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:56:04 +05:30
nitesh 9345f2294c Merge remote-tracking branch 'origin/uate' into uate
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract 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 - 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 E2E Tests / E2E Tests (Shard 10 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 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 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-2) (push) Canceled after 0s
2026-07-31 13:54:07 +05:30
niteshandClaude Sonnet 5 190cd92a85 fix(accounting): resolve Sponsor DD Payable SBI GL account parent by gl_code, update default tenant DB password
The 0345 changelog hardcoded the "OTHER LIABILITY" parent row's numeric id
(22), which only matched the default tenant's auto-increment history and
broke the FK insert (and the whole Liquibase run / app startup) on tenants
seeded differently, e.g. saga. Now resolves parent_id via a SELECT on
gl_code='9' and marks the changeSet runOnChange so it safely re-validates
on tenants that already ran the old version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:53:27 +05:30
225 changed files with 16503 additions and 4970 deletions
+10 -1
View File
@@ -37,4 +37,13 @@ gradleExp/
.windsurf/
# Application logs
fineract-provider/logs/
fineract-provider/logs/
logs/
# Output of a local `gradlew devRun`
devrun.log
# JVM crash dumps. The dev setup runs Fineract, the Angular dev server and Gradle on one
# machine, so an out-of-memory kill drops these in the repo root.
hs_err_pid*.log
replay_pid*.log
+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;
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -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"), //
@@ -36,6 +36,9 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
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.
@@ -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);
}
@@ -86,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;
}
@@ -191,6 +191,7 @@ public class ClientApiConstants {
public static final String constitutionIdParamName = "constitutionId";
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
public static final String authorizedSignatoryParamName = "authorizedSignatory";
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
public static final String additionalInfoParamName = "additionalInfo";
public static final String dateOfRegistrationParamName = "dateOfRegistration";
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
private Boolean alternateDipVerified;
private Long alternateDipLookupLogId;
private String alternateDipTimestamp;
/**
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
* reassigned would otherwise keep showing "Verified" forever.
*/
private String dipLiveStatus;
private String alternateDipLiveStatus;
/**
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
* would keep reading "Clear" after the client was added to a later list snapshot.
*/
private String sanctionsAlertStatus;
private Boolean nameVerified;
private Long screeningLogId;
private Boolean isStaff;
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
private final CodeValueData mainBusinessLine;
private final String remarks;
private final String authorizedSignatory;
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
private final String authorizedSignatoryClientIds;
private final String additionalInfo;
private final LocalDate dateOfRegistration;
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
this.constitution = null;
this.mainBusinessLine = null;
this.authorizedSignatory = null;
this.authorizedSignatoryClientIds = null;
this.additionalInfo = null;
this.dateOfRegistration = null;
}
/**
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
*/
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) {
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration, null);
}
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
this.constitution = constitution;
this.incorpNumber = incorpNo;
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
this.mainBusinessLine = mainBusinessLine;
this.remarks = remarks;
this.authorizedSignatory = authorizedSignatory;
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
this.additionalInfo = additionalInfo;
this.dateOfRegistration = dateOfRegistration;
}
@@ -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;
@@ -241,6 +321,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "is_exception", nullable = false)
private Boolean isException;
@Column(name = "exception_reason", length = 200)
private String exceptionReason;
@Column(name = "exception_resolved", nullable = false)
private Boolean exceptionResolved;
@Column(name = "exception_resolved_on")
private LocalDate exceptionResolvedOn;
// ─────────────────────────────────────────────────────────────────────────
protected ClientKyc() {}
@@ -261,6 +353,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.gstinVerified = false;
this.cinVerified = false;
this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant();
}
@@ -288,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(
@@ -317,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");
@@ -325,15 +441,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("form15Type"), null);
this.form15Fy = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form15FY"), null);
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
this.isException = isExceptionVal != null ? isExceptionVal : false;
this.exceptionReason = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("exceptionReason"), null);
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
}
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);
}
@@ -341,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);
}
@@ -348,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);
}
@@ -355,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);
@@ -367,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);
@@ -392,12 +544,23 @@ 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,
v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
return actualChanges;
}
@@ -407,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));
}
}
@@ -416,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);
}
}
@@ -426,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();
@@ -631,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;
}
@@ -795,6 +1054,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return form15Fy;
}
public Boolean getIsException() {
return isException;
}
public void setIsException(Boolean isException) {
this.isException = isException;
}
public String getExceptionReason() {
return exceptionReason;
}
public void setExceptionReason(String exceptionReason) {
this.exceptionReason = exceptionReason;
}
public Boolean getExceptionResolved() {
return exceptionResolved;
}
public void setExceptionResolved(Boolean exceptionResolved) {
this.exceptionResolved = exceptionResolved;
}
public LocalDate getExceptionResolvedOn() {
return exceptionResolvedOn;
}
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
this.exceptionResolvedOn = exceptionResolvedOn;
}
@Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE;
@@ -846,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;
}
}
@@ -0,0 +1,204 @@
/**
* 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.EnumType;
import jakarta.persistence.Enumerated;
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;
/**
* One customer-outreach attempt against an open KYC exception.
*
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
* Master Direction expects the bank to be able to show what it told the customer and when.
*
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
* browser.
*/
@Entity
@Table(name = "m_client_kyc_contact_log")
public class ClientKycContactLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
@Column(name = "kyc_id")
private Long kycId;
@Enumerated(EnumType.STRING)
@Column(name = "channel", length = 20, nullable = false)
private KycContactChannel channel;
@Column(name = "notes", length = 1000, nullable = false)
private String notes;
@Column(name = "officer_name", length = 200)
private String officerName;
@Column(name = "logged_by_user_id")
private Long loggedByUserId;
@Column(name = "exception_reason", length = 200)
private String exceptionReason;
@Enumerated(EnumType.STRING)
@Column(name = "delivery_status", length = 30, nullable = false)
private KycContactDeliveryStatus deliveryStatus;
@Column(name = "destination_masked", length = 120)
private String destinationMasked;
@Column(name = "provider", length = 40)
private String provider;
@Column(name = "provider_reference", length = 120)
private String providerReference;
@Column(name = "failure_reason", length = 500)
private String failureReason;
@Column(name = "message_body", length = 2000)
private String messageBody;
@Column(name = "contacted_at", nullable = false)
private LocalDateTime contactedAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
protected ClientKycContactLog() {
// JPA
}
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
this.client = client;
this.kycId = kycId;
this.channel = channel;
this.notes = notes;
this.officerName = officerName;
this.loggedByUserId = loggedByUserId;
this.exceptionReason = exceptionReason;
this.contactedAt = contactedAt;
this.createdAt = contactedAt;
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
}
/**
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
* persisted — the entity is append-only afterwards.
*/
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
final String providerReference, final String failureReason, final String messageBody) {
this.deliveryStatus = status;
this.destinationMasked = destinationMasked;
this.provider = provider;
this.providerReference = providerReference;
this.failureReason = truncate(failureReason, 500);
this.messageBody = truncate(messageBody, 2000);
}
private static String truncate(final String value, final int max) {
if (value == null || value.length() <= max) {
return value;
}
return value.substring(0, max - 3) + "...";
}
public Long getId() {
return this.id;
}
public Client getClient() {
return this.client;
}
public Long getKycId() {
return this.kycId;
}
public KycContactChannel getChannel() {
return this.channel;
}
public String getNotes() {
return this.notes;
}
public String getOfficerName() {
return this.officerName;
}
public Long getLoggedByUserId() {
return this.loggedByUserId;
}
public String getExceptionReason() {
return this.exceptionReason;
}
public KycContactDeliveryStatus getDeliveryStatus() {
return this.deliveryStatus;
}
public String getDestinationMasked() {
return this.destinationMasked;
}
public String getProvider() {
return this.provider;
}
public String getProviderReference() {
return this.providerReference;
}
public String getFailureReason() {
return this.failureReason;
}
public String getMessageBody() {
return this.messageBody;
}
public LocalDateTime getContactedAt() {
return this.contactedAt;
}
public LocalDateTime getCreatedAt() {
return this.createdAt;
}
}
@@ -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.client.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
/**
* Every contact log for the given clients in one round trip, so the exceptions grid can render
* "last contacted" for the whole page without an N+1 query per row.
*/
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
}
@@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
boolean existsByClientId(Long clientId);
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
/**
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
*
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
* closed one would be wrong. An earlier version of this query required both flags, which made
* every resolved record invisible in both tabs the moment it was resolved.
*/
List<ClientKyc> findByExceptionResolvedTrue();
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
@@ -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);
}
@@ -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.client.domain;
/**
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
* KYC exception.
*
* <p>The critical distinction is {@link #isPlatformSent()}:
* <ul>
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
* recorded after the fact — the officer already did the thing, the platform only writes it
* down.</li>
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
* actually transmit a notice to the customer at save time.</li>
* </ul>
* Mixing these up would let a "logged" row imply the customer was told something they never
* received, so every call site branches on this flag rather than on the channel name.
*/
public enum KycContactChannel {
PHONE_CALL("Phone Call", false),
SMS("SMS", true),
EMAIL("Email", true),
WHATSAPP("WhatsApp Message", true),
BRANCH_VISIT("In-Branch Visit", false),
FIELD_VISIT("Agent Field Visit", false);
private final String displayLabel;
private final boolean platformSent;
KycContactChannel(final String displayLabel, final boolean platformSent) {
this.displayLabel = displayLabel;
this.platformSent = platformSent;
}
public String getDisplayLabel() {
return this.displayLabel;
}
/** True when saving a log of this channel must actually transmit a message to the customer. */
public boolean isPlatformSent() {
return this.platformSent;
}
/**
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
* labels describe the *purpose* of the message, not a different channel.
*
* @return the matching channel, or {@code null} when nothing matches.
*/
public static KycContactChannel fromString(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String normalised = raw.trim().toUpperCase();
for (final KycContactChannel channel : values()) {
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
return channel;
}
}
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
KycContactChannel best = null;
for (final KycContactChannel channel : values()) {
final String label = channel.displayLabel.toUpperCase();
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
best = channel;
}
}
return best;
}
}
@@ -0,0 +1,48 @@
/**
* 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;
/**
* Outcome of a platform-sent KYC contact notice.
*
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
* failure would understate the bank's outreach obligation during a KYC audit.
*/
public enum KycContactDeliveryStatus {
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
NOT_APPLICABLE,
/** The gateway accepted the message for delivery. */
SENT,
/** The gateway was reached but rejected the message, or the call errored. */
FAILED,
/** The customer has no mobile number / email address on file for this channel. */
SKIPPED_NO_DESTINATION,
/** No gateway is configured for this channel in this deployment. */
PROVIDER_NOT_CONFIGURED;
public boolean isDelivered() {
return this == SENT;
}
}
@@ -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>
@@ -1466,6 +1466,26 @@ public class LoanProductData implements Serializable {
this.allowAttributeOverrides = loanProductConfigurableAttributes;
}
/**
* The legacy "Account Type Creation" fields, flat and keyed by their form field name.
*
* Set after construction rather than through a constructor parameter: the main constructor already
* takes over a hundred arguments, and threading two more through it would touch every call site for
* no benefit. Follows the same pattern as setLoanProductConfigurableAttributes above.
*/
private Map<String, Object> cbsExtensions;
/** The legacy Interest Rate Detail grid: one entry per interest type, in form order. */
private List<Map<String, Object>> interestRateDetail;
public void setCbsExtensions(final Map<String, Object> cbsExtensions) {
this.cbsExtensions = cbsExtensions;
}
public void setInterestRateDetail(final List<Map<String, Object>> interestRateDetail) {
this.interestRateDetail = interestRateDetail;
}
public boolean isIsLinkedToFloatingInterestRates() {
return this.isLinkedToFloatingInterestRates;
}
@@ -0,0 +1,740 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The legacy "Account Type Creation" configuration for a loan product.
*
* Fineract's loan product schema has no home for these fields, and they are specific to this
* deployment, so they hang off {@link LoanProduct} 1:1 instead of widening m_product_loan - an
* upstream change to that table can then never collide with them.
*
* Loan account creation reads this at origination time, which is why these are typed columns rather
* than a JSON blob: callers get a compile-checked getter and reports get a plain column.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_cbs_ext")
public class LoanProductCbsExt {
@Id
@Column(name = "loan_product_id")
private Long loanProductId;
/**
* Shared primary key: this and {@link #loanProductId} are the same column, so the association is
* mapped read-only and {@code loanProductId} is the one that writes it. EclipseLink rejects an
* entity with two writable mappings for one field.
*/
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
private LoanProduct loanProduct;
// ---- classification & legacy header fields (24) ---------------------
@Column(name = "product_type_group")
private String productTypeGroup;
@Column(name = "product_type_sub_group")
private String productTypeSubGroup;
@Column(name = "debit_int_calculation")
private Boolean debitIntCalculation;
@Column(name = "separate_penal_interest")
private Boolean separatePenalInterest;
@Column(name = "credit_int_calculation")
private Boolean creditIntCalculation;
@Column(name = "sub_account_type_balance")
private Boolean subAccountTypeBalance;
@Column(name = "final_int_rounding")
private Boolean finalIntRounding;
@Column(name = "rounding_up_to")
private Integer roundingUpTo;
@Column(name = "account_type_code")
private String accountTypeCode;
@Column(name = "micr_transaction_code")
private String micrTransactionCode;
@Column(name = "reserved_ecs_serial_number")
private String reservedEcsSerialNumber;
@Column(name = "reverse_ecs_number")
private String reverseEcsNumber;
@Column(name = "lock_cheque_info_enabled")
private Boolean lockChequeInfoEnabled;
@Column(name = "lock_page_two_info_enabled")
private Boolean lockPageTwoInfoEnabled;
@Column(name = "computerization_date")
private LocalDate computerizationDate;
@Column(name = "cheque_print_description")
private String chequePrintDescription;
@Column(name = "pos_account_type_description")
private String posAccountTypeDescription;
@Column(name = "pfms_account_type")
private String pfmsAccountType;
@Column(name = "renewal_date_add_months")
private Integer renewalDateAddMonths;
@Column(name = "document_renewal_date_add_months")
private Integer documentRenewalDateAddMonths;
@Column(name = "pps_limit")
private BigDecimal ppsLimit;
@Column(name = "is_write_off_account_type")
private Boolean isWriteOffAccountType;
@Column(name = "write_off_account_type_code")
private String writeOffAccountTypeCode;
@Column(name = "renewal_grace_days")
private Integer renewalGraceDays;
// ---- Cheque Book Settings (10) --------------------------------------
@Column(name = "cheque_book_issue_enabled")
private Boolean chequeBookIssueEnabled;
@Column(name = "cheque_validation_required")
private Boolean chequeValidationRequired;
@Column(name = "is_first_cheque_book_free")
private Boolean isFirstChequeBookFree;
@Column(name = "cheque_printing_account_type")
private String chequePrintingAccountType;
@Column(name = "allow_withdrawal_slip_usage")
private Boolean allowWithdrawalSlipUsage;
@Column(name = "maintain_cheque_book_stock_registry")
private Boolean maintainChequeBookStockRegistry;
@Column(name = "free_cheque_books_per_year")
private Integer freeChequeBooksPerYear;
@Column(name = "allow_credit_by_cheque_number")
private Boolean allowCreditByChequeNumber;
@Column(name = "credit_cheque_validation_required")
private Boolean creditChequeValidationRequired;
@Column(name = "grace_days_for_stock_statement")
private Integer graceDaysForStockStatement;
// ---- Additional Product Settings (37) -------------------------------
@Column(name = "account_type_active_for_transactions")
private Boolean accountTypeActiveForTransactions;
@Column(name = "sub_type_activity_enabled")
private Boolean subTypeActivityEnabled;
@Column(name = "account_opening_charges")
private BigDecimal accountOpeningCharges;
@Column(name = "maximum_gold_loan_limit")
private BigDecimal maximumGoldLoanLimit;
@Column(name = "last_account_number_opened")
private String lastAccountNumberOpened;
@Column(name = "customer_termination_allowed")
private Boolean customerTerminationAllowed;
@Column(name = "charges_for_cash_credit_above_limit")
private BigDecimal chargesForCashCreditAboveLimit;
@Column(name = "death_interest_gl_account")
private String deathInterestGLAccount;
@Column(name = "starting_ledger_number")
private Integer startingLedgerNumber;
@Column(name = "ending_ledger_number")
private Integer endingLedgerNumber;
@Column(name = "current_ledger_number")
private Integer currentLedgerNumber;
@Column(name = "remittance_account_number")
private String remittanceAccountNumber;
@Column(name = "is_non_resident_indian_account")
private Boolean isNonResidentIndianAccount;
@Column(name = "debit_clearing_gl_account")
private String debitClearingGLAccount;
@Column(name = "missed_call_alert_service_active")
private Boolean missedCallAlertServiceActive;
@Column(name = "standing_instruction_charge_gl_account")
private String standingInstructionChargeGLAccount;
@Column(name = "standing_instruction_debit_charge_skip_option")
private String standingInstructionDebitChargeSkipOption;
@Column(name = "opening_balance_multiplier_for_remittance_charges")
private BigDecimal openingBalanceMultiplierForRemittanceCharges;
@Column(name = "gold_loan_packet_number_with_year_enabled")
private Boolean goldLoanPacketNumberWithYearEnabled;
@Column(name = "account_move_to_inoperative_enabled")
private Boolean accountMoveToInoperativeEnabled;
@Column(name = "generate_gl_diff_entry_enabled")
private Boolean generateGlDiffEntryEnabled;
@Column(name = "sub_type_wise_gl_balance_generation_enabled")
private Boolean subTypeWiseGlBalanceGenerationEnabled;
@Column(name = "validate_before_deleting_debit_entry_enabled")
private Boolean validateBeforeDeletingDebitEntryEnabled;
@Column(name = "rc_file_concept_enabled")
private Boolean rcFileConceptEnabled;
@Column(name = "account_close_zero_balance_in_open_day_process")
private Boolean accountCloseZeroBalanceInOpenDayProcess;
@Column(name = "account_close_zero_balance_at_subledger_tran_entry")
private Boolean accountCloseZeroBalanceAtSubledgerTranEntry;
@Column(name = "limit_enhancement_against_clearing_screen_display_enabled")
private Boolean limitEnhancementAgainstClearingScreenDisplayEnabled;
@Column(name = "advance_interest_parameter_in_ledger_enabled")
private Boolean advanceInterestParameterInLedgerEnabled;
@Column(name = "vehicle_detail_in_master_enabled")
private Boolean vehicleDetailInMasterEnabled;
@Column(name = "deleted_cheque_issue_enabled")
private Boolean deletedChequeIssueEnabled;
@Column(name = "dp_reduce_in_document_renewal_date_enabled")
private Boolean dpReduceInDocumentRenewalDateEnabled;
@Column(name = "dp_reduce_in_renewal_date_enabled")
private Boolean dpReduceInRenewalDateEnabled;
@Column(name = "auto_close_at_day_end_enabled")
private Boolean autoCloseAtDayEndEnabled;
@Column(name = "loan_rate_from_master_enabled")
private Boolean loanRateFromMasterEnabled;
@Column(name = "cheque_short_name")
private String chequeShortName;
@Column(name = "statement_print_enabled")
private Boolean statementPrintEnabled;
@Column(name = "nesl_code")
private String neslCode;
// ---- Interest Related Parameters (53) -------------------------------
@Column(name = "drawing_power_and_limit_generation_procedure")
private String drawingPowerAndLimitGenerationProcedure;
@Column(name = "advances_interest_calculation_method")
private String advancesInterestCalculationMethod;
@Column(name = "against_interest_calculation_enabled")
private Boolean againstInterestCalculationEnabled;
@Column(name = "day_wise_against_clearing_interest_rounding")
private Boolean dayWiseAgainstClearingInterestRounding;
@Column(name = "interest_calculation_one_fourth_method")
private String interestCalculationOneFourthMethod;
@Column(name = "against_clearing_interest_procedure_type")
private String againstClearingInterestProcedureType;
@Column(name = "consider_debit_closing_amount_for_against_clearing")
private Boolean considerDebitClosingAmountForAgainstClearing;
@Column(name = "interest_rounding_method")
private String interestRoundingMethod;
@Column(name = "penalty_interest_calculation_enabled")
private Boolean penaltyInterestCalculationEnabled;
@Column(name = "penalty_interest_calculation_method")
private String penaltyInterestCalculationMethod;
@Column(name = "penalty_interest_procedure_number")
private String penaltyInterestProcedureNumber;
@Column(name = "overdrawn_interest_calculation_method")
private String overdrawnInterestCalculationMethod;
@Column(name = "calculate_overdrawn_interest_after_renewal")
private Boolean calculateOverdrawnInterestAfterRenewal;
@Column(name = "interest_on_credit_balance")
private Boolean interestOnCreditBalance;
@Column(name = "penalty_interest_date_different_than_regular_interest")
private Boolean penaltyInterestDateDifferentThanRegularInterest;
@Column(name = "fixed_deposit_overdraft_certificate_sort_order")
private String fixedDepositOverdraftCertificateSortOrder;
@Column(name = "use_document_submission_date_for_interest")
private Boolean useDocumentSubmissionDateForInterest;
@Column(name = "loan_management_system_renewal_penalty_active")
private Boolean loanManagementSystemRenewalPenaltyActive;
@Column(name = "minimum_normal_interest_amount")
private BigDecimal minimumNormalInterestAmount;
@Column(name = "minimum_interest_calculation_method")
private String minimumInterestCalculationMethod;
@Column(name = "minimum_against_clearing_interest_amount")
private BigDecimal minimumAgainstClearingInterestAmount;
@Column(name = "limit_increase_upto_interest_amount_in_interest_calculation")
private Boolean limitIncreaseUptoInterestAmountInInterestCalculation;
@Column(name = "minimum_penalty_interest_amount")
private BigDecimal minimumPenaltyInterestAmount;
@Column(name = "days_for_limit_increase_upto_interest_amount")
private Integer daysForLimitIncreaseUptoInterestAmount;
@Column(name = "additional_interest_rate_for_fixed_deposit_overdraft")
private BigDecimal additionalInterestRateForFixedDepositOverdraft;
@Column(name = "consider_dd_payorder_in_against_clearing_interest_calculation")
private Boolean considerDdPayorderInAgainstClearingInterestCalculation;
@Column(name = "grace_days_after_renew_date")
private Integer graceDaysAfterRenewDate;
@Column(name = "apply_undebited_interest_concept_enabled")
private Boolean applyUndebitedInterestConceptEnabled;
@Column(name = "interest_register_print_format_type")
private String interestRegisterPrintFormatType;
@Column(name = "normal_grace_days_after_overdue")
private Integer normalGraceDaysAfterOverdue;
@Column(name = "allow_against_clearing_entry")
private Boolean allowAgainstClearingEntry;
@Column(name = "allow_no_balance_entry")
private Boolean allowNoBalanceEntry;
@Column(name = "fd_od_penalty_interest_combined_with_normal_interest")
private Boolean fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest;
@Column(name = "allow_no_balance_entry_with_password")
private Boolean allowNoBalanceEntryWithPassword;
@Column(name = "undebited_simple_interest_account_wise")
private Boolean undebitedSimpleInterestAccountWise;
@Column(name = "daily_interest_calculation_enabled")
private Boolean dailyInterestCalculationEnabled;
@Column(name = "daily_interest_calculation_start_date")
private String dailyInterestCalculationStartDate;
@Column(name = "non_performing_asset_penal_interest_rate_update")
private Boolean nonPerformingAssetPenalInterestRateUpdate;
@Column(name = "non_performing_asset_penal_interest_rate")
private BigDecimal nonPerformingAssetPenalInterestRate;
@Column(name = "fd_od_penal_interest_rate_update_in_branch_process")
private Boolean fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess;
@Column(name = "add_penal_interest_rate")
private BigDecimal addPenalInterestRate;
@Column(name = "atyp14_simple_interest_enabled")
private Boolean atyp14SimpleInterestEnabled;
@Column(name = "fixed_deposit_overdraft_margin_type")
private String fixedDepositOverdraftMarginType;
@Column(name = "check_master_detail_edited_passing_pending_enabled")
private Boolean checkMasterDetailEditedPassingPendingEnabled;
@Column(name = "credit_debit_interest_calculation_separate_enabled")
private Boolean creditDebitInterestCalculationSeparateEnabled;
@Column(name = "on_time_interest_provision_enabled")
private Boolean onTimeInterestProvisionEnabled;
@Column(name = "check_against_clearing_enabled")
private Boolean checkAgainstClearingEnabled;
@Column(name = "gst_on_penalty_enabled")
private Boolean gstOnPenaltyEnabled;
@Column(name = "penalty_interest_grace_days")
private Integer penaltyInterestGraceDays;
@Column(name = "overdrawn_interest_rate")
private BigDecimal overdrawnInterestRate;
@Column(name = "calculate_daily_limit_enhancement_tod_enabled")
private Boolean calculateDailyLimitEnhancementTodEnabled;
@Column(name = "fixed_deposit_overdraft_multiple_interest_rate_enabled")
private Boolean fixedDepositOverdraftMultipleInterestRateEnabled;
@Column(name = "grace_days_after_overdue_for_moratorium")
private Integer graceDaysAfterOverdueForMoratorium;
// ---- Credit Bureau Settings (CIBIL) (8) ----------------------------
@Column(name = "cibil_account_type")
private String cibilAccountType;
@Column(name = "cibil_commercial_account_type")
private String cibilCommercialAccountType;
@Column(name = "highmark_account_type")
private String highmarkAccountType;
@Column(name = "highmark_commercial_account_type")
private String highmarkCommercialAccountType;
@Column(name = "experian_account_type")
private String experianAccountType;
@Column(name = "experian_commercial_account_type")
private String experianCommercialAccountType;
@Column(name = "equifax_account_type")
private String equifaxAccountType;
@Column(name = "equifax_commercial_account_type")
private String equifaxCommercialAccountType;
// ---- RBI Compliance & Product Category (6) -------------------------
@Column(name = "loan_product_category")
private String loanProductCategory;
@Column(name = "penal_charge_method")
private String penalChargeMethod;
@Column(name = "is_floating_rate")
private Boolean isFloatingRate;
@Column(name = "foreclosure_charges_applicable")
private Boolean foreclosureChargesApplicable;
@Column(name = "key_fact_statement_enabled")
private Boolean keyFactStatementEnabled;
@Column(name = "annual_percentage_rate_calculation_enabled")
private Boolean annualPercentageRateCalculationEnabled;
// ---- Channel & Transaction Limits (23) ------------------------------
@Column(name = "is_mobile_app_enabled")
private Boolean isMobileAppEnabled;
@Column(name = "is_atm_enabled")
private Boolean isAtmEnabled;
@Column(name = "atm_yearly_charge_enabled")
private Boolean atmYearlyChargeEnabled;
@Column(name = "atm_minimum_balance")
private BigDecimal atmMinimumBalance;
@Column(name = "email_active_enabled")
private Boolean emailActiveEnabled;
@Column(name = "is_imps_enabled")
private Boolean isImpsEnabled;
@Column(name = "is_upi_enabled")
private Boolean isUpiEnabled;
@Column(name = "is_pos_enabled")
private Boolean isPosEnabled;
@Column(name = "is_bbps_enabled")
private Boolean isBbpsEnabled;
@Column(name = "inter_bank_limit_per_day")
private BigDecimal interBankLimitPerDay;
@Column(name = "inter_bank_limit_per_transaction")
private BigDecimal interBankLimitPerTransaction;
@Column(name = "neft_rtgs_limit_per_day")
private BigDecimal neftRtgsLimitPerDay;
@Column(name = "neft_rtgs_limit_per_transaction")
private BigDecimal neftRtgsLimitPerTransaction;
@Column(name = "neft_cutoff_limit")
private BigDecimal neftCutoffLimit;
@Column(name = "rtgs_cutoff_limit")
private BigDecimal rtgsCutoffLimit;
@Column(name = "neft_rtgs_minimum_balance")
private BigDecimal neftRtgsMinimumBalance;
@Column(name = "is_charge_required_neft_rtgs_cbs")
private Boolean isChargeRequiredNeftRtgsCbs;
@Column(name = "is_charge_required_neft_rtgs_mobile")
private Boolean isChargeRequiredNeftRtgsMobile;
@Column(name = "imps_limit_per_day")
private BigDecimal impsLimitPerDay;
@Column(name = "imps_limit_per_transaction")
private BigDecimal impsLimitPerTransaction;
@Column(name = "imps_limit_per_month")
private BigDecimal impsLimitPerMonth;
@Column(name = "imps_min_limit")
private BigDecimal impsMinLimit;
@Column(name = "imps_monthly_transaction_count")
private Integer impsMonthlyTransactionCount;
// ---- POS Facility & ATM CSV Settings (10) ---------------------------
@Column(name = "max_per_transaction_limit_atm")
private BigDecimal maxPerTransactionLimitAtm;
@Column(name = "max_day_transaction_limit_atm")
private BigDecimal maxDayTransactionLimitAtm;
@Column(name = "max_per_transaction_limit_pos")
private BigDecimal maxPerTransactionLimitPos;
@Column(name = "max_day_transaction_limit_pos")
private BigDecimal maxDayTransactionLimitPos;
@Column(name = "max_per_transaction_limit_ecom")
private BigDecimal maxPerTransactionLimitEcom;
@Column(name = "max_day_transaction_limit_ecom")
private BigDecimal maxDayTransactionLimitEcom;
@Column(name = "max_per_transaction_limit_upi")
private BigDecimal maxPerTransactionLimitUpi;
@Column(name = "max_day_transaction_limit_upi")
private BigDecimal maxDayTransactionLimitUpi;
@Column(name = "atm_csv_product_code")
private String atmCsvProductCode;
@Column(name = "atm_csv_type_code")
private String atmCsvTypeCode;
// ---- Auto Interest Post Parameters (4) -----------------------------
@Column(name = "auto_interest_post_type")
private String autoInterestPostType;
@Column(name = "auto_interest_post_when")
private String autoInterestPostWhen;
@Column(name = "auto_interest_post_fix_day")
private Integer autoInterestPostFixDay;
@Column(name = "auto_last_interest_date")
private String autoLastInterestDate;
// ---- Foreclosure / Early Account Closure (3) -----------------------
@Column(name = "calculate_early_account_close_charge")
private Boolean calculateEarlyAccountCloseCharge;
@Column(name = "early_account_close_charge")
private BigDecimal earlyAccountCloseCharge;
@Column(name = "early_account_close_period_in_months")
private Integer earlyAccountClosePeriodInMonths;
// ---- Terms step (3) ------------------------------------------------
@Column(name = "debit_balance_allow")
private Boolean debitBalanceAllow;
@Column(name = "allow_debit_trans_after_due_date")
private Boolean allowDebitTransAfterDueDate;
@Column(name = "branch_level_account_type")
private Boolean branchLevelAccountType;
// ---- legacy cheque / standing instruction / minimum balance charges (24)
@Column(name = "cheque_book_charge_enabled")
private Boolean chequeBookChargeEnabled;
@Column(name = "cheque_return_charge_enabled")
private Boolean chequeReturnChargeEnabled;
@Column(name = "ic_charge_debit_enabled")
private Boolean icChargeDebitEnabled;
@Column(name = "cash_bundle_charge_enabled")
private Boolean cashBundleChargeEnabled;
@Column(name = "cash_bundle_charge_with_pan_limit")
private BigDecimal cashBundleChargeWithPanLimit;
@Column(name = "per_cheque_entry_charge")
private BigDecimal perChequeEntryCharge;
@Column(name = "per_cheque_entry_service_charge")
private BigDecimal perChequeEntryServiceCharge;
@Column(name = "per_inward_cheque_entry_charge")
private BigDecimal perInwardChequeEntryCharge;
@Column(name = "per_outward_cheque_entry_charge")
private BigDecimal perOutwardChequeEntryCharge;
@Column(name = "creditor_standing_instruction_fail_charge")
private BigDecimal creditorStandingInstructionFailCharge;
@Column(name = "debtor_standing_instruction_fail_charge")
private BigDecimal debtorStandingInstructionFailCharge;
@Column(name = "minimum_balance_checking_type")
private String minimumBalanceCheckingType;
@Column(name = "auto_minimum_balance_charge")
private Boolean autoMinimumBalanceCharge;
@Column(name = "calculate_daily_minimum_balance_charges")
private Boolean calculateDailyMinimumBalanceCharges;
@Column(name = "minimum_balance_charge_enabled")
private Boolean minimumBalanceChargeEnabled;
@Column(name = "minimum_balance_charge_amount")
private BigDecimal minimumBalanceChargeAmount;
@Column(name = "minimum_balance_charge_for_cheque_issue")
private BigDecimal minimumBalanceChargeForChequeIssue;
@Column(name = "minimum_balance_charge_method")
private String minimumBalanceChargeMethod;
@Column(name = "months_after_min_balance_charge_calculate")
private Integer monthsAfterMinBalanceChargeCalculate;
@Column(name = "for_cheque_issue")
private BigDecimal forChequeIssue;
@Column(name = "for_cheque_issue_avg")
private BigDecimal forChequeIssueAvg;
@Column(name = "for_withdrawal_slip")
private BigDecimal forWithdrawalSlip;
@Column(name = "for_withdrawal_slip_avg")
private BigDecimal forWithdrawalSlipAvg;
@Column(name = "calculate_daily_against_clearing_charge")
private Boolean calculateDailyAgainstClearingCharge;
// ---- custom GL account mappings (14) --------------------------------
@Column(name = "int_receivables_gl_account_id")
private Long intReceivablesGlAccountId;
@Column(name = "int_payable_gl_account_id")
private Long intPayableGlAccountId;
@Column(name = "deaf_int_payable_gl_account_id")
private Long deafIntPayableGlAccountId;
@Column(name = "int_overdue_reserve_account_id")
private Long intOverdueReserveAccountId;
@Column(name = "undebited_interest_account_id")
private Long undebitedInterestAccountId;
@Column(name = "penal_int_cr_gl_account_id")
private Long penalIntCrGlAccountId;
@Column(name = "rd_matured_gl_account_id")
private Long rdMaturedGlAccountId;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "ic_charge_gl_account_id")
private Long icChargeGlAccountId;
@Column(name = "inoperative_gl_account_id")
private Long inoperativeGlAccountId;
@Column(name = "credit_balance_liability_gl_account_id")
private Long creditBalanceLiabilityGlAccountId;
@Column(name = "credit_balance_asset_gl_account_id")
private Long creditBalanceAssetGlAccountId;
@Column(name = "overdue_accounts_gl_account_id")
private Long overdueAccountsGlAccountId;
@Column(name = "rebate_gl_account_id")
private Long rebateGlAccountId;
public LoanProductCbsExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
}
}
@@ -0,0 +1,20 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductCbsExtRepository extends JpaRepository<LoanProductCbsExt, Long> {
/**
* A product created before this table existed - or one saved without any legacy field filled in - has
* no row here, so callers must handle the empty case rather than assuming one exists.
*/
Optional<LoanProductCbsExt> findByLoanProductId(Long loanProductId);
}
@@ -0,0 +1,82 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of the legacy "Interest Rate Detail" grid on the loan product form.
*
* The grid is a fixed four rows - Normal Debit, Normal Credit, Against Clearing and Penalty - each
* carrying a Normal and a Special min/max rate plus the period they apply over. It is a child table
* rather than twenty-four columns on {@link LoanProductCbsExt} because the interest type reads far
* better as data than as part of a column name: against_clearing_special_max_rate and its siblings
* are almost unreadable, and any new interest type would mean another migration.
*
* The rows are fixed today, so {@link #INTEREST_TYPES} is the full set and the table carries a unique
* constraint on (product, interest type) - a replayed save updates in place instead of doubling the
* grid.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_cbs_interest_rate", uniqueConstraints = {
@UniqueConstraint(columnNames = { "loan_product_id", "interest_type" }, name = "mplcir_unq_product_interest_type") })
public class LoanProductCbsInterestRate extends AbstractPersistableCustom<Long> {
/** The grid's rows, in the order the form shows them. */
public static final String[] INTEREST_TYPES = { "Normal Debit", "Normal Credit", "Against Clearing", "Penalty" };
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "loan_product_id", nullable = false)
private LoanProduct loanProduct;
@Column(name = "interest_type", nullable = false, length = 30)
private String interestType;
@Column(name = "calculation")
private Boolean calculation;
@Column(name = "normal_min_rate", precision = 9, scale = 6)
private BigDecimal normalMinRate;
@Column(name = "normal_max_rate", precision = 9, scale = 6)
private BigDecimal normalMaxRate;
@Column(name = "special_min_rate", precision = 9, scale = 6)
private BigDecimal specialMinRate;
@Column(name = "special_max_rate", precision = 9, scale = 6)
private BigDecimal specialMaxRate;
@Column(name = "period", length = 20)
private String period;
public LoanProductCbsInterestRate(final LoanProduct loanProduct, final String interestType) {
this.loanProduct = loanProduct;
this.interestType = interestType;
}
/** True when the officer left this row untouched, so it need not be persisted. */
public boolean isEmpty() {
return !Boolean.TRUE.equals(this.calculation) && this.normalMinRate == null && this.normalMaxRate == null
&& this.specialMinRate == null && this.specialMaxRate == null && (this.period == null || this.period.isBlank());
}
}
@@ -0,0 +1,18 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductCbsInterestRateRepository extends JpaRepository<LoanProductCbsInterestRate, Long> {
List<LoanProductCbsInterestRate> findByLoanProductIdOrderById(Long loanProductId);
LoanProductCbsInterestRate findByLoanProductIdAndInterestType(Long loanProductId, String interestType);
}
@@ -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());
}
}
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -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());
}
}
@@ -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.");
}
}
}
@@ -102,12 +102,23 @@ public interface ShareAccountApiConstants {
String shareEntityType = "share";
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
String redeemresolutionnumber_paramname = "resolutionNumber";
String redeemmeetingdate_paramname = "meetingDate";
/** Loan this share account is held as margin/security against — optional, set via the
* account update command. When present, a redeem request is checked against the loan's
* outstanding balance. */
String linkedloanid_paramname = "linkedLoanId";
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
numberofshares_paramname, purchasedprice_paramname)));
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
}
@@ -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;
}
@@ -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 {
}
@@ -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;
}
}
@@ -44,6 +44,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
@Override
public List<AmlAlert> evaluate(AmlTransactionContext context) {
@@ -59,6 +60,14 @@ 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;
}
@@ -0,0 +1,841 @@
/**
* 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.clerkwithdrawal.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker withdrawal (redeem shares) request workflow for a Share Account, submitted only
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
* server-side executes the real Fineract "redeemshares" command against the share account, or
* rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
* maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
* redemption is a book-entry, not a cash handover).
*/
@Path("/v1/clerk/share-withdrawal-requests")
@Component
@Tag(name = "Clerk Share Withdrawal Requests", description = "Maker-checker share redemption workflow for clerk and bank manager.")
@RequiredArgsConstructor
public class ClerkShareWithdrawalRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
// simple split('T')[0]/split(' ')[0] date parsing, which expects an ISO-shaped string.
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final String[] LIST_COLUMNS = { "id", "share_account_id", "account_no", "client_id", "client_name",
"requested_shares", "requested_date", "resolution_number", "meeting_date", "description", "status", "clerk_id", "clerk_name",
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submit(final String body) {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
final String requestedDate = fromJsonHelper.extractStringNamed("requestedDate", root);
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final String description = fromJsonHelper.extractStringNamed("description", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
// it, since the CR row's account number is only ever used client-side to double-check against
// the share account's own authoritative linked-savings-account link.
final JsonElement snapshotElement = root.isJsonObject() ? root.getAsJsonObject().get("settlementRowSnapshot") : null;
final String settlementRowSnapshot = snapshotElement != null && !snapshotElement.isJsonNull() ? snapshotElement.toString()
: null;
if (shareAccountId == null) {
throw apiError(400, "Share account id is required");
}
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required");
}
if (requestedShares == null || requestedShares <= 0) {
throw apiError(400, "Number of shares to withdraw must be greater than zero");
}
if (requestedDate == null || requestedDate.isBlank()) {
throw apiError(400, "Requested date is required");
}
if (resolutionNumber == null || resolutionNumber.isBlank()) {
throw apiError(400, "Board Resolution Number is required for a share withdrawal");
}
if (meetingDate == null || meetingDate.isBlank()) {
throw apiError(400, "Date of Meeting is required for a share withdrawal");
}
if (settlementPaymentTypeId == null) {
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
}
validateSettlementPaymentType(settlementPaymentTypeId);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_share_withdrawal_request
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
* Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
* cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
* of what the clerk picked in the UI.
*/
private void validateSettlementPaymentType(final Long paymentTypeId) {
final Map<String, Object> paymentType = jdbcTemplate
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", paymentTypeId).stream().findFirst()
.orElse(null);
if (paymentType == null) {
throw apiError(400, "Settlement Payment Type " + paymentTypeId + " does not exist");
}
final boolean isCash = Boolean.TRUE.equals(paymentType.get("is_cash_payment"));
if (isCash) {
throw apiError(400, "Settlement Payment Type '" + paymentType.get("value")
+ "' is a cash payment type — share redemption settlement to a linked savings account must be a non-cash book-transfer");
}
}
// ── GET /v1/clerk/share-withdrawal-requests ── Manager sees all requests ────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
}
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
@GET
@Path("/my")
@Produces(MediaType.APPLICATION_JSON)
public String myRequests(@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
final AppUser currentUser = context.authenticatedUser();
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
if (status != null && !status.isBlank()) {
where.append(" AND status = ?");
params.add(status);
}
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
}
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
final int offset, final int limit) {
final StringBuilder where = new StringBuilder(baseWhere);
final List<Object> params = new ArrayList<>(baseParams);
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
params.add(Date.valueOf(fromDate));
}
if (toDate != null && !toDate.isBlank()) {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
}
final long total = Optional
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
params.toArray()))
.orElse(0L);
final List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
final JsonObject result = new JsonObject();
result.addProperty("totalFilteredRecords", total);
result.add("pageItems", gson.toJsonTree(rows));
return gson.toJson(result);
}
// ── GET /v1/clerk/share-withdrawal-requests/{id} ── Full detail ─────────────
/**
* Unlike list()/myRequests(), this single-record detail also carries the client's compliance
* summary (KYC status, Risk Category, open AML alert count) — so the review screen can show it
* permanently on the page, not only inside the on-demand /validate popup.
*/
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) {
final Map<?, ?> record = loadRecord(id);
final Map<String, Object> enriched = new java.util.LinkedHashMap<>();
for (final Map.Entry<?, ?> entry : record.entrySet()) {
enriched.put(String.valueOf(entry.getKey()), entry.getValue());
}
final Object clientIdObj = record.get("client_id");
final Long clientId = clientIdObj instanceof Number ? ((Number) clientIdObj).longValue() : null;
enriched.putAll(loadClientComplianceSummary(clientId));
return gson.toJson(enriched);
}
private Map<String, Object> loadClientComplianceSummary(final Long clientId) {
final Map<String, Object> summary = new java.util.LinkedHashMap<>();
if (clientId == null) {
return summary;
}
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
WHERE client_id = ? ORDER BY id DESC LIMIT 1
""", clientId).stream().findFirst().orElse(null);
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null ? ((Number) kycRow.get("riskCategory")).intValue()
: null;
final Long openAlerts = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
summary.put("client_kyc_status_label", kycStatus != null ? kycStatusLabel(kycStatus) : "No KYC record");
summary.put("client_kyc_verified", kycStatus != null && kycStatus == 300);
summary.put("client_risk_category_label", riskCategory != null ? riskCategoryLabel(riskCategory) : "Not classified");
summary.put("client_risk_high", riskCategory != null && riskCategory == 3);
summary.put("client_open_aml_alerts", openAlerts != null ? openAlerts : 0L);
return summary;
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ──
/**
* Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so
* the checker can catch things that changed since submission — a share/savings account that got
* closed, a payment type that got deactivated, a client whose KYC lapsed or who now has an open
* AML alert, an active loan secured by these very shares ({@code m_share_account.linked_loan_id}
* — this bank supports loan-against-shares), that loan's tenure not yet being complete, this
* client currently guaranteeing someone else's active loan, or (the exact bug class this session
* ran into) a savings product's GL Fund Source mapping for this payment type going missing, which
* would silently post the settlement leg to the default Cash at Vault account instead of Share
* Sundry.
*/
@POST
@Path("/{id}/validate")
@Produces(MediaType.APPLICATION_JSON)
public String validate(@PathParam("id") final Long id) {
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
final List<Map<String, Object>> checks = new ArrayList<>();
final String resolutionNumber = (String) record.get("resolution_number");
checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(),
resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber));
final Object meetingDate = record.get("meeting_date");
checks.add(checkResult("Date of Meeting provided", meetingDate != null,
meetingDate == null ? "Missing" : meetingDate.toString().substring(0, Math.min(10, meetingDate.toString().length()))));
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null);
final long heldShares = shareAccountRow != null && shareAccountRow.get("totalApprovedShares") != null
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
: 0L;
checks.add(checkResult("Shares requested within current shares held", shareAccountRow != null && requestedShares <= heldShares,
requestedShares + " of " + heldShares + " shares currently held"));
final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null
? ((Number) shareAccountRow.get("statusEnum")).intValue()
: -1;
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
: null;
final Long clientId = shareAccountRow != null && shareAccountRow.get("clientId") != null
? ((Number) shareAccountRow.get("clientId")).longValue()
: null;
Integer savingsStatus = null;
Long savingsProductId = null;
if (savingsAccountId != null) {
final Map<String, Object> savingsRow = jdbcTemplate
.queryForList("SELECT status_enum AS statusEnum, product_id AS productId FROM m_savings_account WHERE id = ?",
savingsAccountId)
.stream().findFirst().orElse(null);
savingsStatus = savingsRow != null && savingsRow.get("statusEnum") != null ? ((Number) savingsRow.get("statusEnum")).intValue()
: null;
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
: null;
}
checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
savingsAccountId == null ? "No linked savings account configured"
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
// Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id)
// when shares secure a loan against shares, so a withdrawal must not weaken that security while the
// loan is still outstanding, and separately, releasing shares to a client currently on the hook as a
// guarantor for someone else's active loan deserves the same scrutiny.
final Long linkedLoanId = shareAccountRow != null && shareAccountRow.get("linkedLoanId") != null
? ((Number) shareAccountRow.get("linkedLoanId")).longValue()
: null;
Map<String, Object> linkedLoanRow = null;
if (linkedLoanId != null) {
linkedLoanRow = jdbcTemplate.queryForList("""
SELECT account_no AS accountNo, loan_status_id AS statusId, expected_maturedon_date AS expectedMaturedOnDate
FROM m_loan WHERE id = ?
""", linkedLoanId).stream().findFirst().orElse(null);
}
final Integer linkedLoanStatus = linkedLoanRow != null && linkedLoanRow.get("statusId") != null
? ((Number) linkedLoanRow.get("statusId")).intValue()
: null;
final boolean linkedLoanOutstanding = linkedLoanStatus != null && isLoanStatusOutstanding(linkedLoanStatus);
final String loanCheckDetail;
if (linkedLoanId == null) {
loanCheckDetail = "No loan linked to this share account";
} else if (linkedLoanRow == null) {
loanCheckDetail = "Loan #" + linkedLoanId + " — record not found";
} else {
loanCheckDetail = "Loan " + linkedLoanRow.get("accountNo") + " — " + loanStatusLabel(linkedLoanStatus);
}
checks.add(checkResult("Loan Check — no active loan secured by these shares", linkedLoanId == null || !linkedLoanOutstanding,
loanCheckDetail));
boolean tenureOk = true;
String tenureDetail = "No linked loan";
if (linkedLoanId != null && linkedLoanOutstanding && linkedLoanRow != null) {
final Object maturedOnObj = linkedLoanRow.get("expectedMaturedOnDate");
if (maturedOnObj instanceof java.sql.Date maturedOnDate) {
final boolean matured = !maturedOnDate.toLocalDate()
.isAfter(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
tenureOk = matured;
tenureDetail = "Linked loan term ends " + maturedOnDate.toLocalDate() + (matured ? " — completed" : " — not yet completed");
} else {
tenureOk = false;
tenureDetail = "Linked loan is active with no expected maturity date on record";
}
}
checks.add(checkResult("Tenure Check — linked loan term completed", tenureOk, tenureDetail));
if (clientId != null) {
final Long guarantorActiveLoans = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_guarantor g JOIN m_loan l ON l.id = g.loan_id
WHERE g.entity_id = ? AND g.type_enum = 1 AND g.is_active = true
AND l.loan_status_id IN (300, 303, 304, 700)
""", Long.class, clientId);
checks.add(checkResult("Guarantor Check — not guaranteeing any active loan",
guarantorActiveLoans == null || guarantorActiveLoans == 0,
guarantorActiveLoans != null && guarantorActiveLoans > 0 ? "Guarantor on " + guarantorActiveLoans + " active loan(s)"
: "None"));
}
final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id");
final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number
? ((Number) settlementPaymentTypeIdObj).longValue()
: null;
boolean paymentTypeNonCash = false;
String paymentTypeDetail = "Missing";
if (settlementPaymentTypeId != null) {
final Map<String, Object> pt = jdbcTemplate
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream()
.findFirst().orElse(null);
if (pt != null) {
paymentTypeNonCash = !Boolean.TRUE.equals(pt.get("is_cash_payment"));
paymentTypeDetail = pt.get("value") + (paymentTypeNonCash ? "" : " (cash — not allowed)");
}
}
checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail));
boolean glMappingOk = false;
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
if (savingsProductId != null && settlementPaymentTypeId != null) {
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
SELECT gl.name AS glName FROM acc_product_mapping apm
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
glMappingOk = mapping != null;
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
}
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
if (clientId != null) {
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
WHERE client_id = ? ORDER BY id DESC LIMIT 1
""", clientId).stream().findFirst().orElse(null);
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
checks.add(checkResult("Client KYC is Verified", kycStatus != null && kycStatus == 300,
kycStatus == null ? "No KYC record found" : kycStatusLabel(kycStatus)));
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null
? ((Number) kycRow.get("riskCategory")).intValue()
: null;
checks.add(checkResult("Client Risk Category is not High", riskCategory == null || riskCategory != 3,
riskCategory == null ? "Not classified" : riskCategoryLabel(riskCategory)));
final Long openAlerts = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
checks.add(checkResult("No open AML alerts for this client", openAlerts == null || openAlerts == 0,
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
final Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("label", label);
result.put("pass", pass);
result.put("detail", detail);
return result;
}
private boolean isLoanStatusOutstanding(final int loanStatusId) {
return loanStatusId == 300 || loanStatusId == 303 || loanStatusId == 304 || loanStatusId == 700;
}
private String loanStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 303 -> "Transfer in Progress";
case 304 -> "Transfer on Hold";
case 400 -> "Withdrawn by Client";
case 500 -> "Rejected";
case 600 -> "Closed (Obligations Met)";
case 601 -> "Closed (Written Off)";
case 602 -> "Closed (Rescheduled)";
case 700 -> "Overpaid";
default -> "Unknown (" + code + ")";
};
}
private String shareStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 500 -> "Rejected";
case 600 -> "Closed";
default -> "Unknown (" + code + ")";
};
}
private String savingsStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 303 -> "Transfer in Progress";
case 304 -> "Transfer on Hold";
case 400 -> "Withdrawn by Applicant";
case 500 -> "Rejected";
case 600 -> "Closed";
case 700 -> "Pre-mature Closure";
case 800 -> "Matured";
case 900 -> "Terminated";
default -> "Unknown (" + code + ")";
};
}
private String kycStatusLabel(final int code) {
return switch (code) {
case 100 -> "Pending";
case 200 -> "In Progress";
case 300 -> "Verified";
case 400 -> "Rejected";
case 500 -> "Expired";
default -> "Unknown (" + code + ")";
};
}
private String riskCategoryLabel(final int code) {
return switch (code) {
case 1 -> "Low";
case 2 -> "Medium";
case 3 -> "High";
default -> "Unknown (" + code + ")";
};
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/approve ── Manager approves ──
@POST
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403,
"Maker-checker: you cannot approve a share withdrawal request you submitted yourself. Ask another manager to review it.");
}
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Object requestedDateObj = record.get("requested_date");
final Object meetingDateObj = record.get("meeting_date");
final String resolutionNumber = (String) record.get("resolution_number");
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("requestedDate", formatForFineract(requestedDateObj.toString()));
payload.addProperty("requestedShares", requestedShares);
payload.addProperty("resolutionNumber", resolutionNumber);
payload.addProperty("meetingDate", formatForFineract(meetingDateObj.toString()));
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// redeemShares() puts the newly-created share transaction's id under "requestedShares" in
// the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id
// instead. Reading getResourceId() here would silently look up the wrong row downstream.
final Object shareTxnIdRaw = result.getChanges() != null ? result.getChanges().get("requestedShares") : null;
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
// Settlement: within this SAME approval transaction, immediately credit the redemption value
// to the member's linked savings account. Two Fineract journal entries get posted — native
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
? ((Number) storedSettlementPaymentTypeId).longValue()
: null;
final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
resolutionNumber, settlementPaymentTypeId);
final String settlementNote = settlement != null
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
: "No linked savings account on this share account — settlement must be completed manually.";
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
settlementNote);
final int approvedRows = jdbcTemplate.update("""
UPDATE m_clerk_share_withdrawal_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
share_transaction_id = ?, activity_log = ?, actioned_at = ?,
settlement_amount = ?, settlement_savings_transaction_id = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
settlement != null ? settlement.savingsTransactionId() : null, id);
if (approvedRows == 0) {
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");
if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId);
}
if (settlement != null) {
res.addProperty("settlementAmount", settlement.amount());
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
} 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) {}
/**
* Credits the redemption value to the share account's linked savings account, if one exists.
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
* the unit price ourselves — the transaction was already priced by Fineract's own
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
* command so the transaction is fully audited and posts its own GL entry the normal way — the
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
* on that bridge account. Falls back to the legacy hardcoded
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
* column existed.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final String resolutionNumber, final Long settlementPaymentTypeId) {
if (shareTransactionId == null) {
return null;
}
final Map<String, Object> row = jdbcTemplate.queryForList("""
SELECT sa.savings_account_id AS savingsAccountId, sat.amount AS settlementAmount
FROM m_share_account_transactions sat JOIN m_share_account sa ON sa.id = sat.account_id
WHERE sat.id = ?
""", shareTransactionId).stream().findFirst().orElse(null);
if (row == null) {
return null;
}
final Long savingsAccountId = row.get("savingsAccountId") == null ? null : ((Number) row.get("savingsAccountId")).longValue();
if (savingsAccountId == null) {
return null;
}
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId
: jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class,
SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null);
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", formatForFineract(requestedDateStr));
payload.addProperty("transactionAmount", settlementAmount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", paymentTypeId);
}
payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber);
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return new Settlement(settlementAmount, result.getResourceId());
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@POST
@Path("/{id}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
}
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (reason == null || reason.isBlank()) {
throw apiError(400, "Rejection reason is required");
}
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason);
final int rejectedRows = jdbcTemplate.update("""
UPDATE m_clerk_share_withdrawal_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
rejection_reason = ?, activity_log = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), reason, updatedLog,
Timestamp.from(Instant.now()), id);
if (rejectedRows == 0) {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
}
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject share withdrawal requests.");
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new NotFoundException("Share withdrawal request not found: " + id);
}
return rows.get(0);
}
private String formatForFineract(final String isoOrTimestamp) {
try {
final String isoDatePart = isoOrTimestamp.length() >= 10 ? isoOrTimestamp.substring(0, 10) : isoOrTimestamp;
final LocalDate ld = LocalDate.parse(isoDatePart, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
JsonArray log = new JsonArray();
if (existingJson != null && !existingJson.isBlank()) {
try {
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
} catch (Exception e) {
log = new JsonArray();
}
}
final JsonObject entry = new JsonObject();
entry.addProperty("timestamp", Instant.now().toString());
entry.addProperty("action", action);
entry.addProperty("user", username);
if (note != null && !note.isBlank()) {
entry.addProperty("note", note);
}
log.add(entry);
return gson.toJson(log);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -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.");
}
@@ -0,0 +1,111 @@
/**
* 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.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
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.PathParam;
import jakarta.ws.rs.Produces;
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.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.service.KycContactLogService;
import org.springframework.stereotype.Component;
/**
* Customer-outreach log for KYC exceptions.
*
* <pre>
* POST /v1/kyc/exceptions/{clientId}/contact-log body: {"channel":"SMS","notes":"...","officerName":"..."}
* GET /v1/kyc/exceptions/{clientId}/contact-log
* </pre>
*
* <p>POST both records the attempt and — for SMS / Email / WhatsApp — sends the notice to the
* customer. The response carries {@code deliveryStatus} and {@code deliveryMessage} so the caller
* can distinguish a delivered notice from a merely-recorded one; a send failure is reported in the
* body with HTTP 200, not as an error, because the log row was still written.
*
* <p>No PUT or DELETE: the log is append-only evidence.
*
* <p>Authorisation reuses the client UPDATE permission — contacting a customer about their KYC is a
* maintenance action on that customer's record, and every KYC officer who can resolve an exception
* already holds it.
*/
@Path("/v1/kyc/exceptions/{clientId}/contact-log")
@Component
@Tag(name = "Client KYC Exceptions", description = "Record and send customer outreach for KYC exceptions.")
@RequiredArgsConstructor
public class ClientKycContactLogApiResource {
private final PlatformSecurityContext context;
private final KycContactLogService kycContactLogService;
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Log a customer contact attempt for a KYC exception",
description = "Records the attempt. For channel SMS, EMAIL or WHATSAPP the notice is also transmitted to the "
+ "customer using the destination held on their client record. Returns the persisted log including "
+ "its delivery outcome.")
public String logContact(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final JsonObject payload = parse(apiRequestBodyAsJson);
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve the customer-contact history for a KYC exception",
description = "All logged outreach for this customer, oldest first.")
public String retrieveContactLog(@PathParam("clientId") final Long clientId) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<KycContactLogData> logs = this.kycContactLogService.retrieveForClient(clientId);
return this.toApiJsonSerializer.serialize(logs);
}
private JsonObject parse(final String json) {
if (json == null || json.isBlank()) {
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(json);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private String stringOrNull(final JsonObject payload, final String member) {
final JsonElement element = payload.get(member);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
}
@@ -0,0 +1,73 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
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.client.data.ClientKycData;
import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.ws.rs.QueryParam;
@Path("/v1/kyc/exceptions")
@Component
@Tag(name = "Client KYC Exceptions", description = "Retrieve active client KYC exceptions.")
@RequiredArgsConstructor
public class ClientKycExceptionsApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientKycExceptionsApiResource.class);
private final PlatformSecurityContext context;
private final ClientKycReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<ClientKycData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve client KYC exceptions", description = "Returns the list of all clients with active or resolved KYC exceptions.")
public String retrieveActiveExceptions(@QueryParam("resolved") final Boolean resolved) {
this.context.authenticatedUser();
final List<ClientKycData> exceptions;
if (Boolean.TRUE.equals(resolved)) {
exceptions = this.readPlatformService.retrieveResolvedKycExceptions();
LOG.info("retrieveActiveExceptions: fetched {} resolved exception(s) from database", exceptions.size());
} else {
exceptions = this.readPlatformService.retrieveActiveKycExceptions();
LOG.info("retrieveActiveExceptions: fetched {} active exception(s) from database", exceptions.size());
}
for (ClientKycData k : exceptions) {
LOG.info(" -> Exception: id={}, clientId={}, clientName='{}', isException={}, exceptionResolved={}",
k.getId(), k.getClientId(), k.getClientDisplayName(), k.getIsException(), k.getExceptionResolved());
}
return this.toApiJsonSerializer.serialize(exceptions);
}
}
@@ -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);
}
}
@@ -41,6 +41,9 @@ public class ClientKycCommand {
private final Boolean verifyPan;
private final Boolean verifyAadhaar;
private final Boolean verifyGstin;
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
// ── Aadhaar OTP flow ──────────────────────────────────────────────────────
/** Full Aadhaar – used ONLY for OTP generation; never stored. */
@@ -54,7 +57,8 @@ public class ClientKycCommand {
final String aadhaarNumberMasked, final String gstinNumber, final String remarks,
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber,
final String aadhaarOtp) {
final String aadhaarOtp, final Boolean isException, final String exceptionReason,
final Boolean exceptionResolved) {
this.kycType = kycType;
this.riskCategory = riskCategory;
this.panNumber = panNumber;
@@ -68,6 +72,9 @@ public class ClientKycCommand {
this.aadhaarNumber = aadhaarNumber;
this.aadhaarRefNumber = aadhaarRefNumber;
this.aadhaarOtp = aadhaarOtp;
this.isException = isException;
this.exceptionReason = exceptionReason;
this.exceptionResolved = exceptionResolved;
}
public void validateForInitiate() {
@@ -111,8 +118,9 @@ public class ClientKycCommand {
}
// At least one ID document must be provided — except for minors, who prove identity via
// the Birth Certificate / School ID document-type picker instead.
if (!skipIdentityDocumentCheck) {
// the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
final boolean isKycException = isException != null && isException;
if (!skipIdentityDocumentCheck && !isKycException) {
final boolean hasPan = panNumber != null && !panNumber.isBlank();
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
@@ -250,4 +258,16 @@ public class ClientKycCommand {
public String getAadhaarRefNumber() { return aadhaarRefNumber; }
/** OTP entered by the client. */
public String getAadhaarOtp() { return aadhaarOtp; }
public Boolean getIsException() {
return isException;
}
public String getExceptionReason() {
return exceptionReason;
}
public Boolean getExceptionResolved() {
return exceptionResolved;
}
}
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
private final SecureRandom secureRandom = new SecureRandom();
@Autowired
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
final EmailTemplateService emailTemplateService) {
final EmailTemplateService emailTemplateService,
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
this.restTemplate = new RestTemplate();
this.otpLogRepository = otpLogRepository;
this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService;
this.mobileLookupService = mobileLookupService;
}
// ── Generate ─────────────────────────────────────────────────────────────
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
}
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
// credential, so this is refused at the point of dispatch rather than merely warned about
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder().success(false)
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
+ "Contact the customer through an alternate channel and obtain a new number.")
.build();
}
final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
log.setProvider(PROVIDER_LINCPAY);
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
authorizedSignatoryParamName, additionalInfoParamName, dateOfRegistrationParamName, datatables));
authorizedSignatoryParamName, authorizedSignatoryClientIdsParamName, additionalInfoParamName,
dateOfRegistrationParamName, datatables));
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
@@ -70,8 +71,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
dateOfRegistrationParamName));
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
/**
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
@@ -39,6 +39,9 @@ public final class ClientKycApiConstants {
public static final String verifyPanParam = "verifyPan";
public static final String verifyAadhaarParam = "verifyAadhaar";
public static final String verifyGstinParam = "verifyGstin";
public static final String panVerifiedParam = "panVerified";
public static final String aadhaarVerifiedParam = "aadhaarVerified";
public static final String gstinVerifiedParam = "gstinVerified";
// ── Aadhaar OTP flow parameters ───────────────────────────────────────────
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */
@@ -50,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";
@@ -69,11 +88,19 @@ 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";
public static final String form15TypeParam = "form15Type";
public static final String form15FYParam = "form15FY";
public static final String isExceptionParam = "isException";
public static final String exceptionReasonParam = "exceptionReason";
public static final String exceptionResolvedParam = "exceptionResolved";
public static final String exceptionResolvedOnParam = "exceptionResolvedOn";
// ── Supported parameters for initiate/update ──────────────────────────────
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -83,16 +110,30 @@ public final class ClientKycApiConstants {
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
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(
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
riskCategoryParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
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;
@@ -97,10 +127,29 @@ public final class ClientKycData {
private final LocalDate ckycUploadedOn;
private final String ckycBatchId;
// ── Exception management ──────────────────────────────────────────────────
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
private final LocalDate exceptionResolvedOn;
// ── Lifecycle ─────────────────────────────────────────────────────────────
private final LocalDate initiatedOn;
private final LocalDate verifiedOn;
private final LocalDate rejectedOn;
private final LocalDate expiryDate;
private final String remarks;
private final String clientAccountNo;
private final String clientOfficeName;
// ── Customer contact (for KYC outreach) ──────────────────────────────────
// Masked only. The exceptions screen shows these so the officer knows who they are reaching,
// but the full values never leave the server — the send path resolves them from m_client
// itself, so a tampered browser cannot redirect a KYC notice to another address.
private final String clientMobileMasked;
private final String clientEmailMasked;
/** Whether a destination exists at all, so the UI can grey out a channel that cannot be used. */
private final Boolean hasMobile;
private final Boolean hasEmail;
}
@@ -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;
@@ -0,0 +1,64 @@
/**
* 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.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
/**
* One KYC customer-contact attempt as returned to the UI.
*
* <p>{@code deliveryStatus} and {@code deliveryMessage} exist so the officer can tell a delivered
* notice from a failed one at a glance — the dialog shows the failure verbatim rather than a
* generic success toast.
*/
@Getter
@Builder
public final class KycContactLogData {
private final Long id;
private final Long clientId;
private final Long kycId;
/** Enum name, e.g. {@code SMS}. */
private final String channel;
/** Human label, e.g. {@code SMS}/{@code In-Branch Visit} — what the grid renders. */
private final String channelLabel;
private final String notes;
private final String officerName;
private final String exceptionReason;
/** NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED */
private final String deliveryStatus;
/** True only for a channel that was supposed to transmit and did. */
private final Boolean delivered;
/** True when this channel transmits at all (SMS/Email/WhatsApp) vs. is recorded offline. */
private final Boolean platformSent;
/** Officer-facing sentence describing the outcome — success or the reason it failed. */
private final String deliveryMessage;
private final String destinationMasked;
private final String provider;
private final String providerReference;
private final String messageBody;
private final LocalDateTime contactedAt;
}
@@ -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);
}
}
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
@Column(name = "authorized_signatory", length = 500, nullable = true)
private String authorizedSignatory;
/**
* Client ids of the signatories named in {@link #authorizedSignatory}, comma separated and in the
* same order. Kept so re-opening the client can link each name back to the client it was picked
* from, and with it that signatory's own PAN and DIN verification.
*/
@Column(name = "authorized_signatory_client_ids", length = 500, nullable = true)
private String authorizedSignatoryClientIds;
@Column(name = "additional_info", length = 500, nullable = true)
private String additionalInfo;
@@ -99,9 +107,9 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) {
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
additionalInfo, dateOfRegistration);
additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
}
protected ClientNonPerson() {
@@ -110,7 +118,7 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
final LocalDate dateOfRegistration) {
final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
if (client != null) {
this.client = client;
}
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.authorizedSignatory = authorizedSignatory.trim();
}
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
}
if (StringUtils.isNotBlank(additionalInfo)) {
this.additionalInfo = additionalInfo.trim();
}
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName,
this.authorizedSignatoryClientIds)) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName);
actualChanges.put(ClientApiConstants.authorizedSignatoryClientIdsParamName, newValue);
this.authorizedSignatoryClientIds = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
@@ -48,7 +48,10 @@ public class KycVerificationResult {
.build();
}
/** Convenience factory for a failed verification. */
/**
* The provider answered and rejected the document — an unknown CIN, a name that does not match.
* A data problem the officer can act on, not an outage.
*/
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
return KycVerificationResult.builder()
.status(Status.FAILURE)
@@ -57,7 +60,25 @@ public class KycVerificationResult {
.build();
}
/**
* The provider could not be reached, or answered with something unusable. Nothing was learnt
* about the document, so callers should report this as the service being unavailable rather
* than as the document having failed its check.
*/
public static KycVerificationResult error(final String errorMessage, final String rawResponse) {
return KycVerificationResult.builder()
.status(Status.ERROR)
.errorMessage(errorMessage)
.rawResponse(rawResponse)
.build();
}
public boolean isSuccess() {
return Status.SUCCESS == this.status;
}
/** Distinguishes an unreachable provider from one that answered with a rejection. */
public boolean isServiceError() {
return Status.ERROR == this.status;
}
}
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
final JsonObject response = parseJson(rawResponse);
if (response == null) {
return KycVerificationResult.failure(
// Unparseable body: nothing was learnt about the CIN, so this is the provider
// misbehaving rather than the CIN being bad.
return KycVerificationResult.error(
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
rawResponse);
}
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
} catch (final HttpClientErrorException | HttpServerErrorException e) {
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
e.getStatusCode(), e.getResponseBodyAsString());
return KycVerificationResult.failure(
return KycVerificationResult.error(
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
+ e.getResponseBodyAsString(), null);
} catch (final Exception e) {
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
return KycVerificationResult.failure("CIN verification failed: " + e.getMessage(), null);
return KycVerificationResult.error("CIN verification failed: " + e.getMessage(), null);
}
}
@@ -34,7 +34,7 @@ import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Mapper(config = MapstructMapperConfig.class)
public interface ClientMapper {
@@ -107,6 +107,11 @@ public interface ClientMapper {
@Mapping(target = "legalFormId", ignore = true)
@Mapping(target = "clientCollateralManagements", ignore = true)
@Mapping(target = "groups", ignore = true)
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
// ClientReadPlatformServiceImpl#retrieveOne.
@Mapping(target = "dipLiveStatus", ignore = true)
@Mapping(target = "alternateDipLiveStatus", ignore = true)
@Mapping(target = "sanctionsAlertStatus", ignore = true)
ClientData map(Client source);
@Named("clientTypeCode")
@@ -0,0 +1,74 @@
/**
* 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.notification;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Routes an outbound customer notice to the {@link CustomerNotificationSender} registered for its
* channel.
*
* <p>Senders are injected as a list and indexed by channel at startup, so a new gateway becomes
* available by declaring a bean — nothing here changes.
*/
@Component
public class CustomerNotificationDispatcher {
private static final Logger LOG = LoggerFactory.getLogger(CustomerNotificationDispatcher.class);
private final Map<KycContactChannel, CustomerNotificationSender> sendersByChannel = new EnumMap<>(KycContactChannel.class);
public CustomerNotificationDispatcher(final List<CustomerNotificationSender> senders) {
for (final CustomerNotificationSender sender : senders) {
final CustomerNotificationSender existing = this.sendersByChannel.put(sender.getChannel(), sender);
if (existing != null) {
LOG.warn("Two senders registered for channel {} — {} replaced {}", sender.getChannel(),
sender.getClass().getSimpleName(), existing.getClass().getSimpleName());
}
}
LOG.info("Customer notification channels available: {}", this.sendersByChannel.keySet());
}
/**
* Send on the given channel.
*
* @return the outcome; {@link CustomerNotificationResult#notConfigured} for an offline channel
* or one with no registered sender. Never throws for a delivery failure.
*/
public CustomerNotificationResult dispatch(final KycContactChannel channel, final Client client, final String subject,
final String plainBody, final String htmlBody) {
if (!channel.isPlatformSent()) {
// Caller shouldn't reach here for PHONE_CALL / visits, but guard rather than send.
return CustomerNotificationResult.notConfigured(null, "Channel " + channel + " is recorded offline, not transmitted");
}
final CustomerNotificationSender sender = this.sendersByChannel.get(channel);
if (sender == null) {
LOG.warn("No sender registered for channel {} — notice to client {} not transmitted.", channel, client.getId());
return CustomerNotificationResult.notConfigured(null, "No gateway is registered for " + channel);
}
return sender.send(client, subject, plainBody, htmlBody);
}
}
@@ -0,0 +1,51 @@
/**
* 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.notification;
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
/**
* What a {@link CustomerNotificationSender} did. Senders never throw for a delivery failure — they
* return a {@code FAILED} result — so that a gateway outage still produces a persisted contact-log
* row rather than losing the officer's notes to a rolled-back transaction.
*/
public record CustomerNotificationResult(KycContactDeliveryStatus status, String destinationMasked, String provider,
String providerReference, String failureReason) {
public static CustomerNotificationResult sent(final String destinationMasked, final String provider, final String reference) {
return new CustomerNotificationResult(KycContactDeliveryStatus.SENT, destinationMasked, provider, reference, null);
}
public static CustomerNotificationResult failed(final String destinationMasked, final String provider, final String reason) {
return new CustomerNotificationResult(KycContactDeliveryStatus.FAILED, destinationMasked, provider, null, reason);
}
public static CustomerNotificationResult noDestination(final String provider) {
return new CustomerNotificationResult(KycContactDeliveryStatus.SKIPPED_NO_DESTINATION, null, provider, null,
"Customer has no destination on file for this channel");
}
public static CustomerNotificationResult notConfigured(final String provider, final String reason) {
return new CustomerNotificationResult(KycContactDeliveryStatus.PROVIDER_NOT_CONFIGURED, null, provider, null, reason);
}
public boolean isDelivered() {
return this.status.isDelivered();
}
}
@@ -0,0 +1,46 @@
/**
* 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.notification;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
/**
* Transport for one outbound customer channel.
*
* <p>Implementations are discovered by Spring and matched to a channel via {@link #getChannel()},
* so adding a gateway means adding a bean — no dispatcher edit. Each implementation is responsible
* for resolving the destination off the {@link Client} itself; callers never pass a destination in,
* which keeps a browser from being able to redirect a KYC notice to an arbitrary address.
*
* <p>Contract: never throw for a delivery problem. Return
* {@link CustomerNotificationResult#failed} instead, so the officer's contact log still persists.
*/
public interface CustomerNotificationSender {
KycContactChannel getChannel();
/**
* @param client the customer to reach; the destination is read off this record
* @param subject short title — used by channels that have one (email), ignored by those that don't
* @param plainBody the message as plain text; SMS/WhatsApp send this verbatim
* @param htmlBody rich rendering for channels that support it; may be null
*/
CustomerNotificationResult send(Client client, String subject, String plainBody, String htmlBody);
}
@@ -0,0 +1,81 @@
/**
* 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.notification;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Email delivery via {@link EmailSenderService} — the direct-SMTP path configured from this app's
* own {@code spring.mail.*} properties, the same one already used for OTP emails and client-change
* alerts.
*
* <p>Deliberately not {@code PlatformEmailService}: that resolves SMTP credentials from the
* DB-backed "external services" table used by the campaign pipeline, which is a separate and
* typically unconfigured surface in this deployment.
*/
@Component
public class EmailCustomerNotificationSender implements CustomerNotificationSender {
private static final Logger LOG = LoggerFactory.getLogger(EmailCustomerNotificationSender.class);
private static final String PROVIDER = "SMTP";
private final EmailSenderService emailSenderService;
public EmailCustomerNotificationSender(final EmailSenderService emailSenderService) {
this.emailSenderService = emailSenderService;
}
@Override
public KycContactChannel getChannel() {
return KycContactChannel.EMAIL;
}
@Override
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
final String to = client.getEmailAddress();
if (StringUtils.isBlank(to)) {
LOG.warn("KYC contact email skipped — client {} has no email address on file.", client.getId());
return CustomerNotificationResult.noDestination(PROVIDER);
}
final String masked = OtpDestinationMasker.maskEmail(to);
try {
final boolean accepted = StringUtils.isNotBlank(htmlBody)
? this.emailSenderService.sendHtml(to, subject, htmlBody)
: this.emailSenderService.sendText(to, subject, plainBody);
if (accepted) {
LOG.info("KYC contact email sent to client {} at {}", client.getId(), masked);
return CustomerNotificationResult.sent(masked, PROVIDER, null);
}
LOG.warn("KYC contact email rejected by SMTP for client {} at {}", client.getId(), masked);
return CustomerNotificationResult.failed(masked, PROVIDER, "SMTP rejected the message");
} catch (final Exception e) {
LOG.error("KYC contact email failed for client {} at {}", client.getId(), masked, e);
return CustomerNotificationResult.failed(masked, PROVIDER, e.getMessage());
}
}
}
@@ -0,0 +1,213 @@
/**
* 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.notification;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.client.domain.Client;
import org.slf4j.Logger;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
/**
* Shared HTTP plumbing for the SMS and WhatsApp gateways.
*
* <p><b>Why this is template-driven rather than one class per vendor.</b> Indian SMS/WhatsApp
* gateways (MSG91, Gupshup, Kaleyra, Twilio, LincPay, …) all expose the same shape — POST a
* destination plus a body to a URL with a static API key — but disagree on field names, auth header,
* and whether the payload is JSON or form-encoded. Encoding those three differences as properties
* means switching or adding a vendor is a config change, not a code change, and no vendor's
* credentials end up hard-coded in the repo.
*
* <p>The body template supports these placeholders, substituted with correct escaping for the
* configured content type:
* <ul>
* <li>{@code {{destination}}} — the customer's mobile number</li>
* <li>{@code {{message}}} — the notice text</li>
* <li>{@code {{senderId}}} — the DLT-registered sender ID / WhatsApp source number</li>
* <li>{@code {{templateId}}} — the DLT / WhatsApp template ID, where the vendor requires one</li>
* </ul>
*
* <p>TRAI's DLT regime means a transactional SMS to an Indian number is only accepted when the body
* matches a pre-registered template, so {@code templateId} is exposed even though not every vendor
* needs it.
*/
abstract class RestGatewayNotificationSender implements CustomerNotificationSender {
private final RestTemplate restTemplate = new RestTemplate();
protected abstract Logger log();
/** Human-readable gateway name recorded on the contact log (e.g. {@code MSG91}). */
protected abstract String providerName();
protected abstract boolean isEnabled();
protected abstract String baseUrl();
protected abstract String path();
protected abstract String authHeaderName();
protected abstract String authHeaderValue();
protected abstract String contentType();
protected abstract String bodyTemplate();
protected abstract String senderId();
protected abstract String templateId();
/**
* Dotted path into the JSON response holding the vendor's message ID (e.g.
* {@code data.messageId}). Blank when the vendor returns no usable reference.
*/
protected abstract String referenceJsonPath();
/** Resolve the customer's destination for this channel off the client record. */
protected abstract String resolveDestination(Client client);
/** Mask the destination for storage and logging. */
protected abstract String maskDestination(String destination);
@Override
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
final String provider = providerName();
if (!isEnabled()) {
log().warn("{} notice not sent for client {} — channel disabled in configuration.", provider, client.getId());
return CustomerNotificationResult.notConfigured(provider,
"Channel is disabled — set the corresponding fineract.notification.*.enabled property to true");
}
if (StringUtils.isBlank(baseUrl()) || StringUtils.isBlank(authHeaderValue())) {
log().warn("{} notice not sent for client {} — gateway URL or credential missing.", provider, client.getId());
return CustomerNotificationResult.notConfigured(provider, "Gateway base-url or API credential is not configured");
}
final String destination = resolveDestination(client);
if (StringUtils.isBlank(destination)) {
log().warn("{} notice skipped — client {} has no destination on file for this channel.", provider, client.getId());
return CustomerNotificationResult.noDestination(provider);
}
final String masked = maskDestination(destination);
final boolean json = !MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType());
final String payload = renderBody(destination, plainBody, json);
try {
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(contentType()));
if (StringUtils.isNotBlank(authHeaderName())) {
headers.set(authHeaderName(), authHeaderValue());
}
final String base = baseUrl().endsWith("/") ? baseUrl().substring(0, baseUrl().length() - 1) : baseUrl();
final String url = base + path();
final ResponseEntity<String> response = this.restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
if (response.getStatusCode().is2xxSuccessful()) {
final String reference = extractReference(response.getBody());
log().info("{} notice sent to client {} at {} (ref={})", provider, client.getId(), masked, reference);
return CustomerNotificationResult.sent(masked, provider, reference);
}
// Non-2xx without an exception is unusual for RestTemplate but treat it as a failure
// rather than assuming delivery — an over-optimistic SENT would corrupt the audit trail.
log().warn("{} notice rejected for client {} at {} — HTTP {}", provider, client.getId(), masked, response.getStatusCode());
return CustomerNotificationResult.failed(masked, provider, "Gateway returned HTTP " + response.getStatusCode());
} catch (final Exception e) {
log().error("{} notice failed for client {} at {}", provider, client.getId(), masked, e);
return CustomerNotificationResult.failed(masked, provider, e.getMessage());
}
}
/**
* Substitute the placeholders, escaping each value for the target encoding. Escaping happens per
* value rather than on the finished string so that a message containing a quote or an ampersand
* cannot break out of its field and corrupt the request.
*/
private String renderBody(final String destination, final String message, final boolean json) {
String body = bodyTemplate();
body = body.replace("{{destination}}", escape(destination, json));
body = body.replace("{{message}}", escape(message, json));
body = body.replace("{{senderId}}", escape(StringUtils.defaultString(senderId()), json));
body = body.replace("{{templateId}}", escape(StringUtils.defaultString(templateId()), json));
return body;
}
private static String escape(final String value, final boolean json) {
if (value == null) {
return "";
}
if (!json) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
final StringBuilder out = new StringBuilder(value.length() + 16);
for (int i = 0; i < value.length(); i++) {
final char c = value.charAt(i);
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 0x20) {
out.append(String.format("\\u%04x", (int) c));
} else {
out.append(c);
}
}
}
}
return out.toString();
}
/** Walk {@link #referenceJsonPath()} through the response body; null if absent or unparseable. */
private String extractReference(final String responseBody) {
final String jsonPath = referenceJsonPath();
if (StringUtils.isBlank(jsonPath) || StringUtils.isBlank(responseBody)) {
return null;
}
try {
JsonElement node = JsonParser.parseString(responseBody);
for (final String segment : jsonPath.split("\\.")) {
if (node == null || !node.isJsonObject()) {
return null;
}
final JsonObject object = node.getAsJsonObject();
if (!object.has(segment)) {
return null;
}
node = object.get(segment);
}
return node != null && node.isJsonPrimitive() ? node.getAsString() : null;
} catch (final Exception e) {
log().debug("Could not read reference '{}' from {} response", jsonPath, providerName(), e);
return null;
}
}
}
@@ -0,0 +1,151 @@
/**
* 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.notification;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Plain-text SMS via the configured gateway. See {@link RestGatewayNotificationSender} for why the
* request shape is property-driven.
*
* <p>Note this is a <em>separate</em> gateway from the LincPay eKYC endpoints already configured
* for login OTP ({@code /mobile/generate-otp}). That endpoint generates and delivers an OTP of
* LincPay's own choosing; it cannot carry an arbitrary message, and routing a KYC notice through it
* would mint a real, verifiable OTP the customer never requested. Configure a genuine
* send-message endpoint here.
*/
@Component
public class SmsCustomerNotificationSender extends RestGatewayNotificationSender {
private static final Logger LOG = LoggerFactory.getLogger(SmsCustomerNotificationSender.class);
@Value("${fineract.notification.sms.enabled:false}")
private boolean enabled;
@Value("${fineract.notification.sms.provider:SMS_GATEWAY}")
private String provider;
@Value("${fineract.notification.sms.base-url:}")
private String baseUrl;
@Value("${fineract.notification.sms.path:}")
private String path;
@Value("${fineract.notification.sms.auth-header-name:Authorization}")
private String authHeaderName;
@Value("${fineract.notification.sms.auth-header-value:}")
private String authHeaderValue;
@Value("${fineract.notification.sms.content-type:application/json}")
private String contentType;
@Value("${fineract.notification.sms.body-template:}")
private String bodyTemplate;
@Value("${fineract.notification.sms.sender-id:}")
private String senderId;
@Value("${fineract.notification.sms.template-id:}")
private String templateId;
@Value("${fineract.notification.sms.reference-json-path:}")
private String referenceJsonPath;
@Override
public KycContactChannel getChannel() {
return KycContactChannel.SMS;
}
@Override
protected Logger log() {
return LOG;
}
@Override
protected String providerName() {
return this.provider;
}
@Override
protected boolean isEnabled() {
return this.enabled;
}
@Override
protected String baseUrl() {
return this.baseUrl;
}
@Override
protected String path() {
return this.path;
}
@Override
protected String authHeaderName() {
return this.authHeaderName;
}
@Override
protected String authHeaderValue() {
return this.authHeaderValue;
}
@Override
protected String contentType() {
return this.contentType;
}
@Override
protected String bodyTemplate() {
return this.bodyTemplate;
}
@Override
protected String senderId() {
return this.senderId;
}
@Override
protected String templateId() {
return this.templateId;
}
@Override
protected String referenceJsonPath() {
return this.referenceJsonPath;
}
@Override
protected String resolveDestination(final Client client) {
return client.getMobileNo();
}
@Override
protected String maskDestination(final String destination) {
return OtpDestinationMasker.maskMobile(destination);
}
}
@@ -0,0 +1,179 @@
/**
* 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.notification;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* WhatsApp Business delivery via the configured gateway (Meta Cloud API, Gupshup, Twilio, …).
*
* <p>Reuses the customer's registered mobile number as the WhatsApp destination — banks do not
* capture a separate WhatsApp handle, and the Business API is keyed on the phone number anyway.
* Most providers want it in E.164 form, so {@code fineract.notification.whatsapp.country-code} is
* prefixed to bare 10-digit Indian numbers.
*
* <p>Meta's policy only permits business-initiated messages against an approved template outside the
* 24-hour customer service window, which a KYC exception notice always is — so
* {@code template-id} will normally need to be set alongside a template-shaped body.
*/
@Component
public class WhatsAppCustomerNotificationSender extends RestGatewayNotificationSender {
private static final Logger LOG = LoggerFactory.getLogger(WhatsAppCustomerNotificationSender.class);
@Value("${fineract.notification.whatsapp.enabled:false}")
private boolean enabled;
@Value("${fineract.notification.whatsapp.provider:WHATSAPP_GATEWAY}")
private String provider;
@Value("${fineract.notification.whatsapp.base-url:}")
private String baseUrl;
@Value("${fineract.notification.whatsapp.path:}")
private String path;
@Value("${fineract.notification.whatsapp.auth-header-name:Authorization}")
private String authHeaderName;
@Value("${fineract.notification.whatsapp.auth-header-value:}")
private String authHeaderValue;
@Value("${fineract.notification.whatsapp.content-type:application/json}")
private String contentType;
@Value("${fineract.notification.whatsapp.body-template:}")
private String bodyTemplate;
@Value("${fineract.notification.whatsapp.sender-id:}")
private String senderId;
@Value("${fineract.notification.whatsapp.template-id:}")
private String templateId;
@Value("${fineract.notification.whatsapp.reference-json-path:}")
private String referenceJsonPath;
/** Prefixed to bare national numbers to reach E.164. Blank disables normalisation. */
@Value("${fineract.notification.whatsapp.country-code:91}")
private String countryCode;
@Override
public KycContactChannel getChannel() {
return KycContactChannel.WHATSAPP;
}
@Override
protected Logger log() {
return LOG;
}
@Override
protected String providerName() {
return this.provider;
}
@Override
protected boolean isEnabled() {
return this.enabled;
}
@Override
protected String baseUrl() {
return this.baseUrl;
}
@Override
protected String path() {
return this.path;
}
@Override
protected String authHeaderName() {
return this.authHeaderName;
}
@Override
protected String authHeaderValue() {
return this.authHeaderValue;
}
@Override
protected String contentType() {
return this.contentType;
}
@Override
protected String bodyTemplate() {
return this.bodyTemplate;
}
@Override
protected String senderId() {
return this.senderId;
}
@Override
protected String templateId() {
return this.templateId;
}
@Override
protected String referenceJsonPath() {
return this.referenceJsonPath;
}
@Override
protected String resolveDestination(final Client client) {
return toE164(client.getMobileNo());
}
@Override
protected String maskDestination(final String destination) {
return OtpDestinationMasker.maskMobile(destination);
}
/**
* Strip formatting and prefix the country code when the number is stored nationally. A number
* that already carries a country code (leading {@code +}, or longer than 10 digits) is passed
* through so we never double-prefix.
*/
private String toE164(final String mobileNo) {
if (mobileNo == null || mobileNo.isBlank()) {
return null;
}
final String digits = mobileNo.replaceAll("[^0-9+]", "");
if (digits.isBlank()) {
return null;
}
if (digits.startsWith("+")) {
return digits.substring(1);
}
if (this.countryCode == null || this.countryCode.isBlank() || digits.length() > 10) {
return digits;
}
return this.countryCode + digits;
}
}
@@ -76,8 +76,16 @@ public class ClientKycCommandFromApiJsonDeserializer {
final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed(
ClientKycApiConstants.aadhaarOtpParam, element);
final Boolean isException = this.fromApiJsonHelper.extractBooleanNamed(
ClientKycApiConstants.isExceptionParam, element);
final String exceptionReason = this.fromApiJsonHelper.extractStringNamed(
ClientKycApiConstants.exceptionReasonParam, element);
final Boolean exceptionResolved = this.fromApiJsonHelper.extractBooleanNamed(
ClientKycApiConstants.exceptionResolvedParam, element);
return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked,
gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin,
aadhaarNumber, aadhaarRefNumber, aadhaarOtp);
aadhaarNumber, aadhaarRefNumber, aadhaarOtp, isException, exceptionReason,
exceptionResolved);
}
}
@@ -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');
}
}
@@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService {
* Retrieve the KYC record for a client.
*/
ClientKycData retrieveClientKycByClientId(Long clientId);
java.util.List<ClientKycData> retrieveActiveKycExceptions();
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.client.service;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.data.ClientKycData;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
@@ -55,10 +57,19 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue());
final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue());
final String mobileNo = kyc.getClient().getMobileNo();
final String emailAddress = kyc.getClient().getEmailAddress();
return ClientKycData.builder()
.id(kyc.getId())
.clientId(kyc.getClient().getId())
.clientDisplayName(kyc.getClient().getDisplayName())
.clientAccountNo(kyc.getClient().getAccountNumber())
.clientOfficeName(kyc.getClient().getOffice().getName())
.clientMobileMasked(StringUtils.isNotBlank(mobileNo) ? OtpDestinationMasker.maskMobile(mobileNo) : null)
.clientEmailMasked(StringUtils.isNotBlank(emailAddress) ? OtpDestinationMasker.maskEmail(emailAddress) : null)
.hasMobile(StringUtils.isNotBlank(mobileNo))
.hasEmail(StringUtils.isNotBlank(emailAddress))
.kycStatus(status.getValue())
.kycStatusCode(status.getCode())
.kycType(type.getValue())
@@ -74,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())
@@ -95,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())
@@ -103,6 +134,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.ckycNumber(kyc.getCkycNumber())
.ckycUploadedOn(kyc.getCkycUploadedOn())
.ckycBatchId(kyc.getCkycBatchId())
.isException(kyc.getIsException())
.exceptionReason(kyc.getExceptionReason())
.exceptionResolved(kyc.getExceptionResolved())
.exceptionResolvedOn(kyc.getExceptionResolvedOn())
.initiatedOn(kyc.getInitiatedOn())
.verifiedOn(kyc.getVerifiedOn())
.rejectedOn(kyc.getRejectedOn())
@@ -110,4 +145,18 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.remarks(kyc.getRemarks())
.build();
}
@Override
public java.util.List<ClientKycData> retrieveActiveKycExceptions() {
return this.kycRepository.findByIsExceptionTrueAndExceptionResolvedFalse().stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
@Override
public java.util.List<ClientKycData> retrieveResolvedKycExceptions() {
return this.kycRepository.findByExceptionResolvedTrue().stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
}
@@ -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());
@@ -79,10 +79,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
private final ColumnValidator columnValidator;
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final ClientMapper clientMapper;
@Override
@@ -227,6 +230,24 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
final ClientData clientData = clientMapper.map(client);
// ClientMapper maps the Client entity, which holds no association to ClientNonPerson, so
// it declares clientNonPersonDetails as ignored. Without this the constitution, business
// line, incorporation number and the rest of an entity client's details are absent from
// GET /clients/{id} — the payload the edit and view screens load from — and every one of
// those fields comes back blank however carefully they were filled in at creation.
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
// dipVerified is the flag captured when the client was onboarded and never changes
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
// reported for fraud or reassigned since then shows as flagged on the client's page,
// instead of the page continuing to display the old "Verified".
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
// Get client collaterals
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
.getCollateralsPerClient(clientId);
@@ -254,6 +275,57 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
}
}
/**
* The entity-only block of a client, or null for an individual (or an entity whose details row
* was never written). Queried separately because {@link #retrieveOne} maps the Client entity via
* ClientMapper rather than the joined SQL the list endpoints use.
*/
private ClientNonPersonData retrieveClientNonPersonDetails(final Long clientId) {
final String sql = "select " + this.clientNonPersonMapper.schema();
final List<ClientNonPersonData> details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR
clientId);
return details.isEmpty() ? null : details.get(0);
}
private static final class ClientNonPersonDataMapper implements RowMapper<ClientNonPersonData> {
public String schema() {
return " cnp.constitution_cv_id as constitutionId, cvConstitution.code_value as constitutionValue,"
+ " cnp.incorp_no as incorpNo, cnp.incorp_validity_till as incorpValidityTill,"
+ " cnp.main_business_line_cv_id as mainBusinessLineId,"
+ " cvMainBusinessLine.code_value as mainBusinessLineValue, cnp.remarks as remarks,"
+ " cnp.authorized_signatory as authorizedSignatory,"
+ " cnp.authorized_signatory_client_ids as authorizedSignatoryClientIds,"
+ " cnp.additional_info as additionalInfo,"
+ " cnp.date_of_registration as dateOfRegistration" + " from m_client_non_person cnp"
+ " left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id"
+ " left join m_code_value cvMainBusinessLine on cvMainBusinessLine.id = cnp.main_business_line_cv_id"
+ " where cnp.client_id = ?";
}
@Override
public ClientNonPersonData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long constitutionId = JdbcSupport.getLong(rs, "constitutionId");
final String constitutionValue = rs.getString("constitutionValue");
final CodeValueData constitution = CodeValueData.instance(constitutionId, constitutionValue);
final Long mainBusinessLineId = JdbcSupport.getLong(rs, "mainBusinessLineId");
final String mainBusinessLineValue = rs.getString("mainBusinessLineValue");
final CodeValueData mainBusinessLine = CodeValueData.instance(mainBusinessLineId, mainBusinessLineValue);
final String incorpNo = rs.getString("incorpNo");
final LocalDate incorpValidityTill = JdbcSupport.getLocalDate(rs, "incorpValidityTill");
final String remarks = rs.getString("remarks");
final String authorizedSignatory = rs.getString("authorizedSignatory");
final String authorizedSignatoryClientIds = rs.getString("authorizedSignatoryClientIds");
final String additionalInfo = rs.getString("additionalInfo");
final LocalDate dateOfRegistration = JdbcSupport.getLocalDate(rs, "dateOfRegistration");
return new ClientNonPersonData(constitution, incorpNo, incorpValidityTill, mainBusinessLine, remarks,
authorizedSignatory, additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
}
}
@Override
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -134,6 +135,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final ClientRepository clientJpaRepository;
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService;
@@ -156,6 +159,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientNonPersonRepository.delete(clientNonPerson);
}
// Safely cascade-delete child records that have foreign keys pointing to this client
this.jdbcTemplate.update("delete from m_client_identifier where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_family_members where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
this.clientRepository.delete(client);
this.clientRepository.flush();
return new CommandProcessingResultBuilder() //
@@ -611,6 +621,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
newClient.setScreeningLogId(screeningLogId);
}
// UNSC sanctions gate. The UI disables Save & Next on a match, but that is a browser
// control only — screen server-side too, so a direct API call cannot onboard a listed
// person. The name is cleared only by a false positive the Principal Officer approved.
validateNotSanctioned(newClient.getDisplayName());
// DIP/MNRL gate — same reasoning: dipVerified arrives from the browser and cannot be
// trusted on its own. Record the status actually on file alongside it.
validateMobileNumberUsable(newClient.getMobileNo(), "mobileNo");
validateMobileNumberUsable(newClient.getAlternateMobileNo(), "alternateMobileNo");
newClient.setDipStatus(this.mobileLookupService.getLatestStatus(newClient.getMobileNo()));
newClient.setAlternateDipStatus(this.mobileLookupService.getLatestStatus(newClient.getAlternateMobileNo()));
// Account Number generation
this.clientRepository.saveAndFlush(newClient);
if (StringUtils.isBlank(accountNo)) {
@@ -619,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);
@@ -756,6 +778,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String authorizedSignatory = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
clientNonPersonElement);
final LocalDate dateOfRegistration = this.fromApiJsonHelper
@@ -763,7 +787,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration);
dateOfRegistration, authorizedSignatoryClientIds);
this.clientNonPersonRepository.save(newClientNonPerson);
}
@@ -884,12 +908,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
oldMobileNoForNotification = oldValue;
newMobileNoForNotification = newValue;
mobileNoChanged = true;
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
// alert here. Left to the officer as a second manual step it would be forgotten,
// and the queue would fill with cases that were in fact already dealt with.
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
@@ -1466,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");
@@ -1698,6 +1730,82 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
/*
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
*/
/**
* Refuses onboarding of a name that hits the UN Security Council consolidated list.
*
* <p>
* Applies equally to individuals and non-individuals — the display name covers both, and the
* list itself holds INDIVIDUAL and ENTITY records. The only way past this is a false positive
* on the same name that the Principal Officer has approved, which is deliberately a different
* person's decision from the officer who reviewed the hit.
* </p>
*/
private void validateNotSanctioned(final String displayName) {
if (StringUtils.isBlank(displayName)) {
return;
}
if (this.sanctionsScreeningService.hasApprovedFalsePositive(displayName)) {
return;
}
// Non-logging check on purpose — see SanctionsScreeningService#isNameSanctioned.
if (this.sanctionsScreeningService.isNameSanctioned(displayName)) {
throw new GeneralPlatformDomainRuleException("error.msg.client.sanctions.match",
"'" + displayName + "' matched the UN Security Council Consolidated List. Onboarding is blocked until the "
+ "Principal Officer clears the alert.",
displayName);
}
}
/**
* Refuses a mobile number that the DoT Digital Intelligence Platform flagged, unless an
* officer explicitly recorded a decision to proceed with a written reason.
*
* <p>
* A number with no lookup on record passes: DIP verification is enforced in the onboarding
* flow, and failing here would block every legacy update that merely touches an old client.
* What this stops is the case that matters — a number the DoT has actively flagged being
* saved because the browser said it was fine.
* </p>
*/
/**
* Closes any open DIP/MNRL alert on a number that has just been replaced.
*
* <p>
* For an existing customer a flagged number is resolved by obtaining a new one — at which
* point the old alert describes a number the bank no longer uses. Closing it here keeps the
* decision queue a list of work actually outstanding rather than a growing pile of cases that
* were already handled. Failure is logged, never propagated: the client update itself must
* not fail because a bookkeeping row could not be stamped.
* </p>
*/
private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
return;
}
try {
final String reason = StringUtils.isBlank(newNumber)
? "Number removed from the client record on resolution of the DIP/MNRL alert."
: "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
} catch (Exception e) {
log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
e.getMessage());
}
}
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
if (StringUtils.isBlank(mobileNo)) {
return;
}
if (this.mobileLookupService.isMobileNumberUsable(mobileNo.trim())) {
return;
}
throw new GeneralPlatformDomainRuleException("error.msg.client.mobile.dip.flagged",
"Mobile number " + mobileNo + " is flagged on the DoT Digital Intelligence Platform. Record a decision "
+ "to proceed, with reasons, before saving this client.",
parameter);
}
private void validateParentGroupRulesBeforeClientActivation(Client client) {
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
@@ -0,0 +1,47 @@
/**
* 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 java.util.List;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
/**
* Records — and, for the sending channels, actually transmits — a KYC officer's outreach to a
* customer with an open KYC exception.
*/
public interface KycContactLogService {
/**
* Log one contact attempt. For {@code SMS}/{@code EMAIL}/{@code WHATSAPP} this also sends the
* notice to the customer; for the offline channels it only records what the officer already did.
*
* <p>The log row is persisted whether or not delivery succeeded — a gateway outage must not
* discard the officer's notes.
*
* @param clientId customer the exception belongs to
* @param rawChannel channel as submitted by the UI (label or enum name)
* @param notes officer's summary; mandatory
* @param officerName officer selected in the dialog
* @return the persisted log, including its delivery outcome
*/
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
/** All contact attempts for one customer, oldest first. */
List<KycContactLogData> retrieveForClient(Long clientId);
}
@@ -0,0 +1,206 @@
/**
* 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 java.time.LocalDateTime;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycContactLog;
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
import org.apache.fineract.portfolio.client.notification.CustomerNotificationResult;
import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class KycContactLogServiceImpl implements KycContactLogService {
private static final Logger LOG = LoggerFactory.getLogger(KycContactLogServiceImpl.class);
private static final int NOTES_MAX_LENGTH = 1000;
@Value("${fineract.email.from-name:CBSnexora}")
private String bankName;
/** Days the customer is given to act, quoted in the notice body. */
@Value("${fineract.notification.kyc-contact.response-days:15}")
private int responseDays;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final ClientKycRepository kycRepository;
private final ClientKycContactLogRepository contactLogRepository;
private final CustomerNotificationDispatcher dispatcher;
private final EmailTemplateService emailTemplateService;
private final PlatformSecurityContext context;
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
this.clientRepositoryWrapper = clientRepositoryWrapper;
this.kycRepository = kycRepository;
this.contactLogRepository = contactLogRepository;
this.dispatcher = dispatcher;
this.emailTemplateService = emailTemplateService;
this.context = context;
}
@Override
@Transactional
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
if (channel == null) {
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
}
if (StringUtils.isBlank(notes)) {
throw validationError("notes", "A summary of the contact is required.");
}
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
final AppUser currentUser = this.context.authenticatedUser();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
if (channel.isPlatformSent()) {
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
final String plainBody = buildPlainBody(client, exceptionReason, channel);
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
final String htmlBody = channel == KycContactChannel.EMAIL
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
: null;
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
result.failureReason(), plainBody);
if (!result.isDelivered()) {
// Logged, not thrown: the officer's notes are still worth persisting, and the UI
// surfaces the failure so nobody assumes the customer was reached.
LOG.warn("KYC contact notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel,
result.status(), result.failureReason());
}
}
final ClientKycContactLog saved = this.contactLogRepository.saveAndFlush(log);
return toData(saved);
}
@Override
@Transactional(readOnly = true)
public List<KycContactLogData> retrieveForClient(final Long clientId) {
return this.contactLogRepository.findByClientIdOrderByContactedAtAsc(clientId).stream().map(this::toData).toList();
}
/**
* The notice text. Kept short and channel-aware because an SMS over 160 GSM-7 characters is
* billed and delivered as multiple segments; email can afford the longer framing but reuses the
* same sentence so the audit trail shows one consistent message across channels.
*/
private String buildPlainBody(final Client client, final String exceptionReason, final KycContactChannel channel) {
final String reason = StringUtils.isNotBlank(exceptionReason) ? exceptionReason : "KYC verification is incomplete";
if (channel == KycContactChannel.SMS) {
return "Dear " + displayName(client) + ", your KYC is pending: " + reason + ". Please visit your branch with valid "
+ "documents within " + this.responseDays + " days to avoid account restrictions. - " + this.bankName;
}
return "Dear " + displayName(client) + ", our records show that your KYC (Know Your Customer) verification is pending "
+ "for the following reason: " + reason + ". As required by the Reserve Bank of India's KYC Master Direction, "
+ "please visit your home branch with valid identity and address documents within " + this.responseDays
+ " days. If this is not completed, operations on your account may be restricted. "
+ "If you have already submitted these documents, please ignore this message. - " + this.bankName;
}
private KycContactLogData toData(final ClientKycContactLog log) {
final KycContactChannel channel = log.getChannel();
final KycContactDeliveryStatus status = log.getDeliveryStatus();
return KycContactLogData.builder()
.id(log.getId())
.clientId(log.getClient().getId())
.kycId(log.getKycId())
.channel(channel.name())
.channelLabel(channel.getDisplayLabel())
.notes(log.getNotes())
.officerName(log.getOfficerName())
.exceptionReason(log.getExceptionReason())
.deliveryStatus(status.name())
.delivered(status.isDelivered())
.platformSent(channel.isPlatformSent())
.deliveryMessage(describeDelivery(channel, status, log.getDestinationMasked(), log.getFailureReason()))
.destinationMasked(log.getDestinationMasked())
.provider(log.getProvider())
.providerReference(log.getProviderReference())
.messageBody(log.getMessageBody())
.contactedAt(log.getContactedAt())
.build();
}
/** Officer-facing outcome sentence. Failures name the cause so the next action is obvious. */
private String describeDelivery(final KycContactChannel channel, final KycContactDeliveryStatus status, final String destination,
final String failureReason) {
return switch (status) {
case NOT_APPLICABLE -> channel.getDisplayLabel() + " recorded.";
case SENT -> channel.getDisplayLabel() + " sent to " + (StringUtils.isNotBlank(destination) ? destination : "the customer")
+ ".";
case SKIPPED_NO_DESTINATION -> "Not sent — the customer has no "
+ (channel == KycContactChannel.EMAIL ? "email address" : "mobile number") + " on file.";
case PROVIDER_NOT_CONFIGURED -> "Not sent — no " + channel.getDisplayLabel()
+ " gateway is configured. The attempt has been logged.";
case FAILED -> "Not sent — the gateway rejected the message"
+ (StringUtils.isNotBlank(failureReason) ? " (" + failureReason + ")" : "") + ". The attempt has been logged.";
};
}
private String displayName(final Client client) {
if (StringUtils.isNotBlank(client.getFirstname())) {
return client.getFirstname();
}
if (StringUtils.isNotBlank(client.getDisplayName())) {
return client.getDisplayName();
}
return "Customer";
}
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.contact.log." + parameter + ".invalid",
message, parameter);
return new PlatformApiDataValidationException(List.of(error));
}
}
@@ -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);
}
@@ -48,6 +48,15 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
}
@Override
public KycRiskRuleData retrieveRule(final Long ruleId) {
final ClientRiskRule rule = this.repository.findById(ruleId)
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
final AmlTransactionRule txnRule = this.amlTransactionRuleRepository.findAll().stream()
.filter(txn -> ruleId.equals(txn.getSourceClientRiskRuleId())).findFirst().orElse(null);
return mapToData(rule, txnRule);
}
private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
@@ -0,0 +1,34 @@
/**
* 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.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
public interface KycRiskRulesWritePlatformService {
/**
* Adds a rule to the AML catalog. Creates one {@code m_client_risk_rule} row and, when the
* request describes a transaction band or observation window, a companion
* {@code m_aml_transaction_rule} row linked back to it.
*
* @return the persisted rule exactly as the catalog GET would return it
*/
KycRiskRuleData createRule(KycRiskRuleRequest request);
}

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