Compare commits

..
15 Commits
Author SHA1 Message Date
keshav 1107610c08 add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
2026-08-11 11:34:54 +05:30
niteshandClaude Sonnet 5 2213308254 fix(shares): skip zero-value redeem transaction on account close
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Closing a share account that already has zero approved shares no
longer synthesizes a redeem transaction, so it stops posting a
meaningless ₹0.00/₹0.00 GL journal-entry pair. Save/note-capture on
close now happen unconditionally instead of being gated on a
transaction existing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 20:10:57 +05:30
nitesh c568eb0abf feat(shares): close share account directly on withdrawal approval
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Replaces the auto-submit-closure-request behavior with an immediate
account close, in the same @Transactional approval action, when a
redemption brings a share account's holding to zero — no separate
"Share Closure Request" maker-checker step is needed since the
redemption amount itself already went through this same review.
Removes ClerkShareClosureRequestApiResource entirely (the migration
for m_clerk_share_closure_request is left in place as a harmless,
now-unused table, to avoid a Liquibase checksum churn).

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

Also dropped two JVM crash-dump log files that got staged by mistake.
2026-08-06 16:49:15 +05:30
niteshandClaude Sonnet 5 66a6a4766f feat(shares): add Share Account Closure maker-checker workflow
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
New ClerkShareClosureRequestApiResource mirrors the withdrawal resource:
clerk submits a closure request only once a share account holds zero
shares; a /validate endpoint re-checks shares-held, account status, any
active loan/guarantee, and client KYC/AML compliance; approve() executes
the real native "close" command.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:44:47 +05:30
Anshul Rathore b9aa29fb35 feat: commit latest changes on dev
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-06 10:46:22 +05:30
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
Anshul Rathore 84f8aa2274 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev 2026-08-03 12:51:55 +05:30
keshav 7773a9e9c6 feat(aml): add API endpoints and services for manual journal entry referrals
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-03 10:49:37 +05:30
keshav c890faee78 form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
2026-08-02 11:45:54 +05:30
keshav abc7eb22a2 oss file updated, profitl loss as per the day month and also with date select
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-01 11:54:30 +05:30
139 changed files with 7485 additions and 179 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ gitVersioning.apply {
} }
ext['groovy.version'] = '4.0.17' 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 // Apply static weaving configuration to all subprojects
subprojects { subproject -> subprojects { subproject ->
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class> <class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class> <class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
+1 -1
View File
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
// Configure Java compilation // Configure Java compilation
tasks.named('compileJava') { tasks.named('compileJava') {
outputs.cacheIf { true } outputs.cacheIf { true }
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply) dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles) mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
} }
@@ -53,6 +53,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class> <class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class> <class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -35,4 +35,13 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
@Param("since") LocalDateTime since); @Param("since") LocalDateTime since);
long countByClientId(Long clientId); long countByClientId(Long clientId);
/** Alerts for one customer inside the recurring-behaviour observation window. */
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
/**
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
} }
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
*
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
*/
@Entity
@Table(name = "m_aml_recurring_review")
public class AmlRecurringReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "alert_count_at_review", nullable = false)
private Integer alertCountAtReview;
@Column(name = "outcome", nullable = false, length = 30)
private String outcome;
@Column(name = "notes", nullable = false, length = 1000)
private String notes;
@ManyToOne(optional = false)
@JoinColumn(name = "reviewed_by", nullable = false)
private AppUser reviewedBy;
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
final AmlRecurringReview review = new AmlRecurringReview();
review.client = client;
review.alertCountAtReview = alertCountAtReview;
review.outcome = outcome.name();
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
return review;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
public Integer getAlertCountAtReview() {
return alertCountAtReview;
}
public AmlRecurringReviewOutcome getOutcome() {
return AmlRecurringReviewOutcome.valueOf(outcome);
}
public String getNotes() {
return notes;
}
public AppUser getReviewedBy() {
return reviewedBy;
}
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** What a reviewer concluded about a customer's repeated AML activity. */
public enum AmlRecurringReviewOutcome {
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
CLEARED,
/** Not conclusive; keep watching this customer more closely. */
MONITOR,
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
ESCALATED
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
/** Newest first, so the caller can take the head as the latest review for a customer. */
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
}
@@ -52,6 +52,26 @@ public class AmlTransactionRule {
@Column(name = "threshold_amount", precision = 19, scale = 6) @Column(name = "threshold_amount", precision = 19, scale = 6)
private BigDecimal thresholdAmount; private BigDecimal thresholdAmount;
/** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is
* not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */
@Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6)
private BigDecimal thresholdAmountMediumRisk;
@Column(name = "threshold_amount_high_risk", precision = 19, scale = 6)
private BigDecimal thresholdAmountHighRisk;
/**
* Declarative band the rule covers, shown on the AML Rules Master screen. Purely
* descriptive: unlike the tiered columns above these never take part in matching, so
* changing them cannot alter which transactions raise an alert. A null maximum means
* the rule is genuinely open-ended.
*/
@Column(name = "min_limit_amount", precision = 19, scale = 6)
private BigDecimal minLimitAmount;
@Column(name = "max_limit_amount", precision = 19, scale = 6)
private BigDecimal maxLimitAmount;
@Column(name = "rule_type", nullable = false, length = 20) @Column(name = "rule_type", nullable = false, length = 20)
private String ruleType; private String ruleType;
@@ -66,6 +86,29 @@ public class AmlTransactionRule {
protected 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() { public Long getId() {
return id; return id;
} }
@@ -86,6 +129,22 @@ public class AmlTransactionRule {
return thresholdAmount; return thresholdAmount;
} }
public BigDecimal getThresholdAmountMediumRisk() {
return thresholdAmountMediumRisk;
}
public BigDecimal getThresholdAmountHighRisk() {
return thresholdAmountHighRisk;
}
public BigDecimal getMinLimitAmount() {
return minLimitAmount;
}
public BigDecimal getMaxLimitAmount() {
return maxLimitAmount;
}
public AmlRuleType getRuleType() { public AmlRuleType getRuleType() {
return AmlRuleType.valueOf(ruleType); return AmlRuleType.valueOf(ruleType);
} }
@@ -19,9 +19,17 @@
package org.apache.fineract.portfolio.aml.domain; package org.apache.fineract.portfolio.aml.domain;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> { public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo); List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
/**
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
} }
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class ClientFamilyMemberChildData implements Serializable {
private final Long id;
private final String fullName;
private final Long genderId;
private final String gender;
private final LocalDate dateOfBirth;
}
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
private final LocalDate marriageDate; private final LocalDate marriageDate;
private final String marriageAuthority; private final String marriageAuthority;
@lombok.Setter
private java.util.List<ClientFamilyMemberChildData> children;
// template holder // template holder
private final Collection<CodeValueData> relationshipIdOptions; private final Collection<CodeValueData> relationshipIdOptions;
private final Collection<CodeValueData> genderIdOptions; private final Collection<CodeValueData> genderIdOptions;
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate; import java.time.LocalDate;
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "is_pwd") @Column(name = "is_pwd")
private Boolean isPwd; private Boolean isPwd;
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
private ClientKyc clientKyc;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pwd_category_cv_id") @JoinColumn(name = "pwd_category_cv_id")
private CodeValue pwdCategory; private CodeValue pwdCategory;
@@ -27,6 +27,7 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate; import java.time.LocalDate;
import java.math.BigDecimal;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -103,6 +104,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "pan_status", length = 20) @Column(name = "pan_status", length = 20)
private String panStatus; 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 ────────────────────────────────────────────────────────────── // ── Aadhaar ──────────────────────────────────────────────────────────────
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */ /** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
@Column(name = "aadhaar_number_masked", length = 12) @Column(name = "aadhaar_number_masked", length = 12)
@@ -114,6 +145,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "aadhaar_verified_on") @Column(name = "aadhaar_verified_on")
private LocalDate aadhaarVerifiedOn; 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) ───────────────────────── // ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
@Column(name = "gstin_number", length = 15) @Column(name = "gstin_number", length = 15)
private String gstinNumber; private String gstinNumber;
@@ -195,6 +251,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "freeze_authority", length = 100) @Column(name = "freeze_authority", length = 100)
private String freezeAuthority; 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 ─────────────────────────────────────────────── // ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
@Column(name = "form60_type", length = 20) @Column(name = "form60_type", length = 20)
private String form60Type; private String form60Type;
@@ -241,6 +309,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "remarks", length = 500) @Column(name = "remarks", length = 500)
private String remarks; 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() {} protected ClientKyc() {}
@@ -261,6 +341,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.gstinVerified = false; this.gstinVerified = false;
this.cinVerified = false; this.cinVerified = false;
this.dinVerified = false; this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant(); this.initiatedOn = DateUtils.getLocalDateOfTenant();
} }
@@ -288,6 +370,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
private void applyExtendedFields(final JsonCommand command) { private void applyExtendedFields(final JsonCommand command) {
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty( this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus); 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( this.enterprisePan = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("enterprisePan"), null); command.stringValueOfParameterNamed("enterprisePan"), null);
this.cinNumber = StringUtils.defaultIfEmpty( this.cinNumber = StringUtils.defaultIfEmpty(
@@ -317,6 +417,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("freezeReason"), null); command.stringValueOfParameterNamed("freezeReason"), null);
this.freezeAuthority = StringUtils.defaultIfEmpty( this.freezeAuthority = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("freezeAuthority"), null); 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( this.form60Type = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form60Type"), null); command.stringValueOfParameterNamed("form60Type"), null);
this.form60Date = command.localDateValueOfParameterNamed("form60Date"); this.form60Date = command.localDateValueOfParameterNamed("form60Date");
@@ -325,6 +429,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("form15Type"), null); command.stringValueOfParameterNamed("form15Type"), null);
this.form15Fy = StringUtils.defaultIfEmpty( this.form15Fy = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form15FY"), null); 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) { public Map<String, Object> update(final JsonCommand command) {
@@ -367,6 +478,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges, updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
v -> this.panAadhaarSeedingStatus = v); 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, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v); updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v); updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
@@ -392,12 +525,23 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v); updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges, updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
v -> this.freezeAuthority = v); 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); updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v); updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges, updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
v -> this.form15Applicable = v); v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v); updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = 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; return actualChanges;
} }
@@ -431,6 +575,15 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} }
} }
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);
setter.accept(newValue);
}
}
/** Called when documents are submitted and 3rd-party verification is initiated. */ /** Called when documents are submitted and 3rd-party verification is initiated. */
public void markInProgress() { public void markInProgress() {
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue(); this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
@@ -631,6 +784,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return panStatus; 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() { public String getAadhaarNumberMasked() {
return aadhaarNumberMasked; return aadhaarNumberMasked;
} }
@@ -795,6 +1002,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return form15Fy; 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) @Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE; private Boolean isActive = Boolean.TRUE;
@@ -846,4 +1085,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public Long getVersion() { public Long getVersion() {
return this.version; 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); 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") @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer 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") @Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
void removeSignatureId(@Param("clientId") Long 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 ─────────────────────────────────────────── // ── Pre-save uniqueness probes ───────────────────────────────────────────
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear // 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. // field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
@@ -95,6 +95,44 @@ public class ClientRiskRule {
protected 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() { public Long getId() {
return id; return id;
} }
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC") @Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
List<ClientRiskRule> findAllOrderedByOrderPosition(); 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;
}
}
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class> <class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class> <class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.infrastructure.documentmanagement.service; package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
@Service @Service
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService { 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 @Deprecated
private final List<EntityImageIdAdapter> imageIdAdapters; private final List<EntityImageIdAdapter> imageIdAdapters;
private final ContentStoreService storeService; private final ContentStoreService storeService;
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
} catch (final AbstractPlatformDomainRuleException e) { } catch (final AbstractPlatformDomainRuleException e) {
throw e; throw e;
} catch (final Exception 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); 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.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class> <class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class> <class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -154,6 +154,11 @@ public class SecurityConfig {
// a Fineract Bearer token; security is enforced at the JWT envelope // a Fineract Bearer token; security is enforced at the JWT envelope
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler. // (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
// Client edit approval email open-tracking pixel — hit by the client's email client
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
// path is the only capability credential; the endpoint always returns a GIF so
// token validity is not leaked via different HTTP responses.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed // NPCI clearing-house receiver — the real, separate NPCI service pushes routed
// packages here without a Fineract Bearer token; security is enforced by the // packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
@@ -102,12 +102,23 @@ public interface ShareAccountApiConstants {
String shareEntityType = "share"; 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, 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, id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname, resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname, requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname, minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname, allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
numberofshares_paramname, purchasedprice_paramname))); numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
} }
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlAlertData; import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService; import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService; import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/aml/alerts") @Path("/v1/aml/alerts")
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final AmlAlertReadPlatformService readPlatformService; private final AmlAlertReadPlatformService readPlatformService;
private final AmlAlertWritePlatformService writePlatformService; private final AmlAlertWritePlatformService writePlatformService;
private final JournalEntryReferralService referralService;
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer; private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
@GET @GET
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
.orElse(null); .orElse(null);
return this.toApiJsonSerializer.serialize(updated); return this.toApiJsonSerializer.serialize(updated);
} }
@GET
@Path("manual-referrals")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List ledger entries marked by a user",
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
public String retrieveManualReferrals() {
this.context.authenticatedUser();
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
}
@POST
@Path("manual-referral")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Mark a ledger entry cleared or suspicious",
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
public String referJournalEntry(final ManualReferralRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
request.getNotes());
return this.referralJsonSerializer.serialize(referral);
}
} }
@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/recurring-alerts")
@Component
@Tag(name = "AML Recurring Alerts",
description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.")
@RequiredArgsConstructor
public class AmlRecurringAlertApiResource {
private final PlatformSecurityContext context;
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final AmlRecurringAlertWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<AmlRecurringAlertData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the recurring-alert review queue",
description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. "
+ "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.")
public String retrieveRecurringAlerts(
@QueryParam("minAlerts") @DefaultValue("3") final int minAlerts,
@QueryParam("windowDays") @DefaultValue("90") final int windowDays,
@QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) {
this.context.authenticatedUser();
final List<AmlRecurringAlertData> rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1),
onlyNeedingReview);
return this.toApiJsonSerializer.serialize(rows);
}
@POST
@Path("{clientId}/review")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a review of a customer's repeated AML activity",
description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert "
+ "count grows beyond the count reviewed here.")
public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(),
request.getWindowDays() == null ? 0 : request.getWindowDays());
return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}";
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */
@Getter
@Setter
@NoArgsConstructor
public class AmlRecurringReviewRequest {
@Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR")
private String outcome;
@Schema(description = "Why the customer keeps surfacing and what was decided",
requiredMode = Schema.RequiredMode.REQUIRED)
private String notes;
@Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it",
example = "90")
private Integer windowDays;
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
*
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
* no report) or ESCALATE (raises an STR case for the Principal Officer).
*/
@Getter
@Setter
@NoArgsConstructor
public class ManualReferralRequest {
private Long journalEntryId;
private String disposition;
private String notes;
}
@@ -22,7 +22,14 @@ import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; 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, 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 ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable { String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
implements Serializable {
} }
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* One customer who has tripped the AML engine repeatedly inside the observation window.
*
* @param alertCount
* alerts in the window — what the threshold is compared against
* @param highRiskCount
* how many of those came from a High severity catalog rule
* @param distinctRules
* number of different rules involved; one rule firing five times is a weaker signal than
* five different rules firing once, and the reviewer needs to see which it is
* @param openCount
* alerts still awaiting a per-transaction disposition
* @param escalatedCount
* alerts already escalated to an STR case
* @param needsReview
* true when there is no review yet, or new alerts have arrived since the last one
* @param alerts
* the triggering transactions themselves, so the reviewer can see why this customer keeps
* surfacing without leaving the row
*/
public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory,
int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount,
LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List<String> ruleCodes, boolean needsReview,
Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn,
String lastReviewNotes, LocalDate windowFrom, List<AmlAlertData> alerts) implements Serializable {
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
*
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
*/
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
@@ -19,12 +19,15 @@
package org.apache.fineract.portfolio.aml.service; package org.apache.fineract.portfolio.aml.service;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData; import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService { public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
private final AmlAlertRepository repository; private final AmlAlertRepository repository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
@Override @Override
public List<AmlAlertData> retrieve(String statusFilter) { public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll() final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter); : repository.findByStatusOrderByRaisedOnAsc(statusFilter);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData) // Resolved once for the whole page rather than per alert: many alerts share a rule, so a
.collect(Collectors.toList()); // 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(), 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.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(), alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(), alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name()); alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
} }
} }
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import org.apache.fineract.portfolio.client.domain.Client;
public interface AmlRecurringAlertNotificationService {
/**
* Called once per transaction after the engine has saved its alerts. Raises a notification for
* AML reviewers when this customer's alert count crosses the recurring-behaviour threshold.
*
* @param alertsJustRaised
* how many alerts this transaction produced, so the count before it can be derived
*/
void onAlertsRaised(Client client, int alertsJustRaised);
}
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.notification.service.UserNotificationService;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.springframework.stereotype.Service;
/**
* Pushes a notification to AML reviewers when one customer keeps tripping the engine.
*
* The review queue on the AML Rules Master screen is the durable record; this is the nudge that
* stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the
* customer's office, resolved by {@link UserNotificationService}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService {
/**
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already
* treats as "this customer has done this enough times to warrant another look".
*/
private static final int RECURRING_THRESHOLD = 3;
private static final int WINDOW_DAYS = 90;
private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT";
private static final String OBJECT_TYPE = "AML_RECURRING";
private static final String EVENT_TYPE = "recurringAmlActivity";
private final AmlAlertRepository alertRepository;
private final UserNotificationService userNotificationService;
@Override
public void onAlertsRaised(final Client client, final int alertsJustRaised) {
if (client == null || alertsJustRaised <= 0) {
return;
}
try {
final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay();
final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since);
final long before = after - alertsJustRaised;
// Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every
// alert once the threshold is passed. Notifying every time would bury the reviewer in
// duplicates for a customer they have already been told about; notifying only on the
// first crossing would go permanently silent on a customer who keeps offending.
if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) {
return;
}
final String content = String.format(
"%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.",
client.getDisplayName(), after, WINDOW_DAYS);
this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null,
client.getOffice() == null ? null : client.getOffice().getId());
log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after);
} catch (final RuntimeException e) {
// Consistent with AmlDomainServiceImpl: AML observation must never break the transaction
// it is watching, and a notification is the least important part of that observation.
log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e);
}
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
public interface AmlRecurringAlertReadPlatformService {
/**
* Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}.
*
* @param onlyNeedingReview
* when true, hides customers already reviewed at their current alert count
*/
List<AmlRecurringAlertData> retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview);
/** Alerts in the window for one customer, used to record a review against the count seen. */
int alertCountInWindow(Long clientId, int windowDays);
}
@@ -0,0 +1,134 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Builds the recurring-alert review queue.
*
* The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so
* the rule-severity resolution lives in exactly one place and a customer's High-risk count here can
* never disagree with the Risk Category shown on the alert itself.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService {
private static final int HIGH_RISK = 3;
private final AmlAlertReadPlatformService alertReadPlatformService;
private final AmlRecurringReviewRepository reviewRepository;
private final JdbcTemplate jdbcTemplate;
@Override
public List<AmlRecurringAlertData> retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) {
final LocalDate windowFrom = LocalDate.now().minusDays(windowDays);
final LocalDateTime cutoff = windowFrom.atStartOfDay();
// Grouped in insertion order of the (already newest-first) alert list, so each customer's
// nested alerts stay newest-first without a second sort.
final Map<Long, List<AmlAlertData>> byClient = this.alertReadPlatformService.retrieve(null).stream()
.filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff))
.collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList()));
final List<Long> qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts)
.map(Map.Entry::getKey).toList();
if (qualifying.isEmpty()) {
return List.of();
}
final Map<Long, ClientSummary> summaries = clientSummaries(qualifying);
final List<AmlRecurringAlertData> rows = new ArrayList<>();
for (final Long clientId : qualifying) {
final List<AmlAlertData> alerts = byClient.get(clientId);
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
.orElse(null);
// Reviewed at a lower count means new alerts have landed since — the customer has to
// come back, otherwise one early review would silence them permanently.
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
if (onlyNeedingReview && !needsReview) {
continue;
}
final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null));
rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(),
summary.riskCategory(), alerts.size(),
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null),
alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview,
lastReview == null ? null : lastReview.getAlertCountAtReview(),
lastReview == null ? null : lastReview.getOutcome().name(),
lastReview == null ? null : lastReview.getReviewedBy().getUsername(),
lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(),
windowFrom, alerts));
}
// Worst offenders first; most recent activity breaks ties.
rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed()
.thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder())));
return rows;
}
@Override
public int alertCountInWindow(final Long clientId, final int windowDays) {
final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay();
return (int) this.alertReadPlatformService.retrieve(null).stream()
.filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count();
}
/** Account number and current KYC risk rating, fetched once for the whole queue. */
private Map<Long, ClientSummary> clientSummaries(final List<Long> clientIds) {
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory "
+ "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")";
final Map<Long, ClientSummary> byId = new HashMap<>();
this.jdbcTemplate.query(sql, rs -> {
byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"),
rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory")));
}, clientIds.toArray());
return byId;
}
private record ClientSummary(String accountNo, Integer riskCategory) {}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
public interface AmlRecurringAlertWritePlatformService {
/**
* Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count
* the reviewer was looking at so the customer only resurfaces when that count grows.
*
* @param windowDays
* observation window the reviewer had on screen, so the recorded count matches it
* @return the id of the stored review
*/
Long recordReview(Long clientId, String outcome, String notes, int windowDays);
}
@@ -0,0 +1,91 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService {
private static final String RESOURCE = "amlrecurringreview";
private static final int DEFAULT_WINDOW_DAYS = 90;
private final AmlRecurringReviewRepository reviewRepository;
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
@Override
@Transactional
public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) {
final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes);
final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS;
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
// Counted here rather than taken from the request: a caller-supplied count could be used to
// silence a customer at an inflated number they were never actually reviewed at.
final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window);
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
this.context.authenticatedUser());
return this.reviewRepository.saveAndFlush(review).getId();
}
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
validator.reset().parameter("clientId").value(clientId).notNull();
validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000);
validator.reset().parameter("outcome").value(outcome).notBlank();
AmlRecurringReviewOutcome parsed = null;
if (StringUtils.isNotBlank(outcome)) {
for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) {
if (candidate.name().equalsIgnoreCase(outcome.trim())) {
parsed = candidate;
break;
}
}
if (parsed == null) {
validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported");
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
return parsed;
}
}
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.aml.service; package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -27,6 +28,8 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo; import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule; import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -35,8 +38,13 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional @Transactional
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
private static final int HIGH_RISK = 3;
private final AmlTransactionRuleRepository ruleRepository; private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository; private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
@Override @Override
public List<AmlAlert> evaluate(AmlTransactionContext context) { public List<AmlAlert> evaluate(AmlTransactionContext context) {
@@ -52,24 +60,54 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
raised.add(alertRepository.save(alert)); 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; return raised;
} }
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) { private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (rule.getThresholdAmount() == null) { final BigDecimal threshold = resolveThreshold(rule, context);
if (threshold == null) {
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic // Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
// per rule and are not yet evaluated here - they exist in the catalog for future work. // per rule and are not yet evaluated here - they exist in the catalog for future work.
return false; return false;
} }
switch (rule.getRuleType()) { switch (rule.getRuleType()) {
case THRESHOLD: case THRESHOLD:
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0; return context.getAmount().compareTo(threshold) >= 0;
case STRUCTURING: case STRUCTURING:
case VELOCITY: case VELOCITY:
return context.getWindowAggregateAmount() != null return context.getWindowAggregateAmount() != null
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0; && context.getWindowAggregateAmount().compareTo(threshold) >= 0;
default: default:
return false; return false;
} }
} }
/**
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
* clients.
*/
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
return rule.getThresholdAmount();
}
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
.orElse(null);
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
return rule.getThresholdAmountHighRisk();
}
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
return rule.getThresholdAmountMediumRisk();
}
return rule.getThresholdAmount();
}
} }
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
/**
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
*
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
*/
public interface JournalEntryReferralService {
/** disposition: WHITELIST | ESCALATE. */
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
List<ManualReferralData> retrieveAll();
}
@@ -0,0 +1,224 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
private final JdbcTemplate jdbcTemplate;
private final AmlAlertRepository alertRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlTransactionRuleRepository ruleRepository;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
/**
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
* posted against.
*
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
* a savings-type entity may be either a savings or a current account, which live in separate tables.
*
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
je.savings_transaction_id AS savingsTxnId,
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
l.client_id,
ct.client_id
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
LEFT JOIN m_loan l ON l.id = lt.loan_id
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
WHERE je.id = ?
""";
private static final String LIST_SQL = """
SELECT a.id AS alertId,
a.transaction_id AS journalEntryId,
a.client_id AS clientId,
c.display_name AS clientDisplayName,
a.amount AS amount,
a.currency_code AS currencyCode,
a.raised_on AS raisedOn,
a.status AS status,
a.disposition AS disposition,
u.username AS reviewedByUsername,
a.review_notes AS reviewNotes,
s.id AS strCaseId
FROM m_aml_alert a
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
LEFT JOIN m_client c ON c.id = a.client_id
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
ORDER BY a.raised_on DESC
""";
@Override
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
if (journalEntryId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
"A journal entry must be given to refer.");
}
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
"Disposition must be WHITELIST or ESCALATE.", disposition);
}
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
// Marking the same entry twice would put two cases in front of the Principal Officer for
// one transaction, so the first mark stands until it is explicitly reworked.
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
"This ledger entry has already been marked.", journalEntryId);
}
final Map<String, Object> row = loadEntry(journalEntryId);
final Number clientIdValue = (Number) row.get("clientId");
if (clientIdValue == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
}
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
final BigDecimal amount = (BigDecimal) row.get("amount");
final String currencyCode = (String) row.get("currencyCode");
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
// savings or loan transaction id.
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
LocalDateTime.now());
alertRepository.save(alert);
Long strCaseId = null;
if (escalate) {
alert.escalate(context.authenticatedUser(), notes);
alertRepository.save(alert);
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
strCaseId = strCase.getId();
} else {
alert.whitelist(context.authenticatedUser(), notes);
alertRepository.save(alert);
}
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
alert.getDisposition() == null ? null : alert.getDisposition().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
strCaseId);
}
@Override
@Transactional(readOnly = true)
public List<ManualReferralData> retrieveAll() {
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
MANUAL_RULE_CODE);
}
private Map<String, Object> loadEntry(final Long journalEntryId) {
try {
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
} catch (final EmptyResultDataAccessException e) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
}
}
/**
* TRANSFER covers everything that is neither a savings nor a loan posting.
*
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
* id; falling straight through to the transaction columns would mislabel those as transfers.
*/
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
final Number entityType = (Number) row.get("entityType");
if (entityType != null) {
if (entityType.intValue() == 2) {
return AmlTransactionType.SAVINGS;
}
if (entityType.intValue() == 1) {
return AmlTransactionType.LOAN;
}
}
if (row.get("savingsTxnId") != null) {
return AmlTransactionType.SAVINGS;
}
if (row.get("loanTxnId") != null) {
return AmlTransactionType.LOAN;
}
return AmlTransactionType.TRANSFER;
}
}
@@ -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());
}
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData; import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService; import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService; import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/clients") @Path("/v1/clients")
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ClientEditApiResource { public class ClientEditApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
* whether the token matched, to avoid leaking token validity via response differences. */
private static final byte[] TRANSPARENT_GIF = {
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
};
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final ClientEditWritePlatformService clientEditWritePlatformService; private final ClientEditWritePlatformService clientEditWritePlatformService;
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService; private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final ApiRequestParameterHelper apiRequestParameterHelper; private final ApiRequestParameterHelper apiRequestParameterHelper;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer; private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Client details edit", @Operation(summary = "Approve a pending Client details edit",
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.") description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) { + "The maker of the edit cannot also approve it — no exceptions.")
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Context final HttpServletRequest httpRequest) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) { if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to approve client edits"); throw new NoAuthorizationException("User has no authority to approve client edits");
} }
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId); // Derive base URL from the incoming request so the tracking pixel URL is absolute and
// reachable by the client's email client without needing a configured server.url property.
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
: ":" + httpRequest.getServerPort());
// Tenant ID from the Fineract-specific header (same header the frontend sends).
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
tenantId != null ? tenantId : "default");
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
// ── Email preview (authenticated) ─────────────────────────────────────────
@GET
@Path("{clientId}/edit/{pendingEditId}/email-preview")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
if (preview == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
}
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
@GET
@Path("edit/pixel/{token}")
@Produces("image/gif")
@Operation(summary = "Email open tracking pixel",
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
+ "regardless of whether the token matched, to avoid leaking token validity.")
public Response trackEmailOpen(@PathParam("token") final String token) {
if (token != null && !token.isBlank()) {
try {
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
if (pendingEditOpt.isPresent()) {
final var pendingEdit = pendingEditOpt.get();
pendingEdit.markEmailOpened();
pendingEditRepository.saveAndFlush(pendingEdit);
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
}
} catch (final Exception e) {
// Never fail a pixel request — email clients don't handle errors well.
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
}
}
return Response.ok(TRANSPARENT_GIF, "image/gif")
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
.header("Pragma", "no-cache")
.build();
}
} }
@@ -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 io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; 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.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData; 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.KycRiskRulesReadPlatformService;
import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/kyc/risk-rules") @Path("/v1/kyc/risk-rules")
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final KycRiskRulesReadPlatformService readPlatformService; private final KycRiskRulesReadPlatformService readPlatformService;
private final KycRiskRulesWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer; private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
@GET @GET
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules(); final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
return this.toApiJsonSerializer.serialize(rules); 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 verifyPan;
private final Boolean verifyAadhaar; private final Boolean verifyAadhaar;
private final Boolean verifyGstin; private final Boolean verifyGstin;
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
// ── Aadhaar OTP flow ────────────────────────────────────────────────────── // ── Aadhaar OTP flow ──────────────────────────────────────────────────────
/** Full Aadhaar – used ONLY for OTP generation; never stored. */ /** 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 aadhaarNumberMasked, final String gstinNumber, final String remarks,
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar, final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber, 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.kycType = kycType;
this.riskCategory = riskCategory; this.riskCategory = riskCategory;
this.panNumber = panNumber; this.panNumber = panNumber;
@@ -68,6 +72,9 @@ public class ClientKycCommand {
this.aadhaarNumber = aadhaarNumber; this.aadhaarNumber = aadhaarNumber;
this.aadhaarRefNumber = aadhaarRefNumber; this.aadhaarRefNumber = aadhaarRefNumber;
this.aadhaarOtp = aadhaarOtp; this.aadhaarOtp = aadhaarOtp;
this.isException = isException;
this.exceptionReason = exceptionReason;
this.exceptionResolved = exceptionResolved;
} }
public void validateForInitiate() { 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 // At least one ID document must be provided — except for minors, who prove identity via
// the Birth Certificate / School ID document-type picker instead. // the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
if (!skipIdentityDocumentCheck) { final boolean isKycException = isException != null && isException;
if (!skipIdentityDocumentCheck && !isKycException) {
final boolean hasPan = panNumber != null && !panNumber.isBlank(); final boolean hasPan = panNumber != null && !panNumber.isBlank();
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank(); final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank(); final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
@@ -250,4 +258,16 @@ public class ClientKycCommand {
public String getAadhaarRefNumber() { return aadhaarRefNumber; } public String getAadhaarRefNumber() { return aadhaarRefNumber; }
/** OTP entered by the client. */ /** OTP entered by the client. */
public String getAadhaarOtp() { return aadhaarOtp; } public String getAadhaarOtp() { return aadhaarOtp; }
public Boolean getIsException() {
return isException;
}
public String getExceptionReason() {
return exceptionReason;
}
public Boolean getExceptionResolved() {
return exceptionResolved;
}
} }
@@ -65,7 +65,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName, alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
nameVerifiedParamName, screeningLogIdParamName, nameVerifiedParamName, screeningLogIdParamName,
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName, submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName)); mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
"oldMobileVerificationExceptionDocumentId"));
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName, Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull() baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
.longGreaterThanZero(); .longGreaterThanZero();
} }
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) { if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element); final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull() baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
@@ -39,6 +39,9 @@ public final class ClientKycApiConstants {
public static final String verifyPanParam = "verifyPan"; public static final String verifyPanParam = "verifyPan";
public static final String verifyAadhaarParam = "verifyAadhaar"; public static final String verifyAadhaarParam = "verifyAadhaar";
public static final String verifyGstinParam = "verifyGstin"; 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 ─────────────────────────────────────────── // ── Aadhaar OTP flow parameters ───────────────────────────────────────────
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */ /** 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 ────────────────────────────────────────────────── // ── Extended KYC fields ──────────────────────────────────────────────────
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus"; 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 enterprisePanParam = "enterprisePan";
public static final String cinNumberParam = "cinNumber"; public static final String cinNumberParam = "cinNumber";
public static final String dinNumberParam = "dinNumber"; public static final String dinNumberParam = "dinNumber";
@@ -69,11 +88,19 @@ public final class ClientKycApiConstants {
public static final String accountStatusParam = "accountStatus"; public static final String accountStatusParam = "accountStatus";
public static final String freezeReasonParam = "freezeReason"; public static final String freezeReasonParam = "freezeReason";
public static final String freezeAuthorityParam = "freezeAuthority"; 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 form60TypeParam = "form60Type";
public static final String form60DateParam = "form60Date"; public static final String form60DateParam = "form60Date";
public static final String form15ApplicableParam = "form15Applicable"; public static final String form15ApplicableParam = "form15Applicable";
public static final String form15TypeParam = "form15Type"; public static final String form15TypeParam = "form15Type";
public static final String form15FYParam = "form15FY"; 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 ────────────────────────────── // ── Supported parameters for initiate/update ──────────────────────────────
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -83,16 +110,30 @@ public final class ClientKycApiConstants {
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, 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( public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
riskCategoryParam, remarksParam, "locale", "dateFormat", riskCategoryParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, 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. */ /** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.data; package org.apache.fineract.portfolio.client.data;
import java.time.LocalDate; import java.time.LocalDate;
import java.math.BigDecimal;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -52,11 +53,29 @@ public final class ClientKycData {
private final String panCategory; private final String panCategory;
private final String panStatus; 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 ─────────────────────────────────────────────────────────────── // ── Aadhaar ───────────────────────────────────────────────────────────────
private final String aadhaarNumberMasked; private final String aadhaarNumberMasked;
private final Boolean aadhaarVerified; private final Boolean aadhaarVerified;
private final LocalDate aadhaarVerifiedOn; 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 ───────────────────────────────────────────────────────────────── // ── GSTIN ─────────────────────────────────────────────────────────────────
private final String gstinNumber; private final String gstinNumber;
private final Boolean gstinVerified; private final Boolean gstinVerified;
@@ -65,6 +84,8 @@ public final class ClientKycData {
// ── Enterprise / Non-Individual ────────────────────────────────────────── // ── Enterprise / Non-Individual ──────────────────────────────────────────
private final String enterprisePan; private final String enterprisePan;
private final String cinNumber; private final String cinNumber;
private final Boolean cinVerified;
private final LocalDate cinVerifiedOn;
private final LocalDate incorporationDate; private final LocalDate incorporationDate;
// ── KYC Document ───────────────────────────────────────────────────────── // ── KYC Document ─────────────────────────────────────────────────────────
@@ -84,6 +105,10 @@ public final class ClientKycData {
private final String accountStatus; private final String accountStatus;
private final String freezeReason; private final String freezeReason;
private final String freezeAuthority; 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 ─────────────────────────────────────────────── // ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
private final String form60Type; private final String form60Type;
@@ -97,10 +122,29 @@ public final class ClientKycData {
private final LocalDate ckycUploadedOn; private final LocalDate ckycUploadedOn;
private final String ckycBatchId; private final String ckycBatchId;
// ── Exception management ──────────────────────────────────────────────────
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
private final LocalDate exceptionResolvedOn;
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
private final LocalDate initiatedOn; private final LocalDate initiatedOn;
private final LocalDate verifiedOn; private final LocalDate verifiedOn;
private final LocalDate rejectedOn; private final LocalDate rejectedOn;
private final LocalDate expiryDate; private final LocalDate expiryDate;
private final String remarks; 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; package org.apache.fineract.portfolio.client.data;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Data; import lombok.Data;
/** /**
@@ -51,6 +52,45 @@ public class ClientKycRequest {
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED") @Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
private String panAadhaarSeedingStatus; 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") @Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
private String enterprisePan; private String enterprisePan;
@@ -96,6 +136,18 @@ public class ClientKycRequest {
@Schema(description = "Authority that ordered the freeze (null if not frozen)") @Schema(description = "Authority that ordered the freeze (null if not frozen)")
private String freezeAuthority; 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") @Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
private String form60Type; private String form60Type;
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
private final String checkerName; private final String checkerName;
private final OffsetDateTime checkedDate; private final OffsetDateTime checkedDate;
private final String rejectReason; private final String rejectReason;
// ── Email tracking fields ─────────────────────────────────────────────────
/** Whether a confirmation email was successfully sent to the client after approval. */
private final Boolean emailSent;
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
private final OffsetDateTime emailSentAt;
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
private final OffsetDateTime emailOpenedAt;
// emailSubject and emailBody are deliberately NOT included in the list DTO —
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
} }
@@ -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;
}
@@ -46,6 +46,20 @@ public final class KycRiskRuleData implements Serializable {
private final String willfulDefaulter; private final String willfulDefaulter;
private final String ecgcSalFlag; private final String ecgcSalFlag;
private final String dormantFlag; private final String dormantFlag;
/** Populated only for rules that also have a live-transaction-monitoring counterpart in
* {@code m_aml_transaction_rule} (linked via source_client_risk_rule_id) - distinct from the
* client-onboarding income/turnover fields above, which drive KYC-time risk categorization. */
private final BigDecimal txnThresholdAmount;
private final String txnRuleType;
private final Integer txnWindowDays;
/** Risk-category-tiered overrides of {@link #txnThresholdAmount}, populated only when the
* linked {@code m_aml_transaction_rule} row has tiering configured (e.g. RTGS monitoring). */
private final BigDecimal txnThresholdAmountMediumRisk;
private final BigDecimal txnThresholdAmountHighRisk;
/** Declarative band the rule covers, for display on the AML Rules Master screen. Never
* used in matching; a null maximum means the rule is genuinely open-ended. */
private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount;
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId, public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName, final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -55,7 +69,10 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
final Integer riskCategory, final String reason, final Integer orderPosition, final Integer riskCategory, final String reason, final Integer orderPosition,
final String amlCheckStatus, final String bannedEntityMatch, final String amlCheckStatus, final String bannedEntityMatch,
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag) { final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id; this.id = id;
this.ruleName = ruleName; this.ruleName = ruleName;
this.legalFormId = legalFormId; this.legalFormId = legalFormId;
@@ -77,6 +94,13 @@ public final class KycRiskRuleData implements Serializable {
this.willfulDefaulter = willfulDefaulter; this.willfulDefaulter = willfulDefaulter;
this.ecgcSalFlag = ecgcSalFlag; this.ecgcSalFlag = ecgcSalFlag;
this.dormantFlag = dormantFlag; this.dormantFlag = dormantFlag;
this.txnThresholdAmount = txnThresholdAmount;
this.txnRuleType = txnRuleType;
this.txnWindowDays = txnWindowDays;
this.txnThresholdAmountMediumRisk = txnThresholdAmountMediumRisk;
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
this.txnMinLimitAmount = txnMinLimitAmount;
this.txnMaxLimitAmount = txnMaxLimitAmount;
} }
public Long getId() { public Long getId() {
@@ -162,4 +186,32 @@ public final class KycRiskRuleData implements Serializable {
public String getDormantFlag() { public String getDormantFlag() {
return dormantFlag; return dormantFlag;
} }
public BigDecimal getTxnThresholdAmount() {
return txnThresholdAmount;
}
public String getTxnRuleType() {
return txnRuleType;
}
public Integer getTxnWindowDays() {
return txnWindowDays;
}
public BigDecimal getTxnThresholdAmountMediumRisk() {
return txnThresholdAmountMediumRisk;
}
public BigDecimal getTxnThresholdAmountHighRisk() {
return txnThresholdAmountHighRisk;
}
public BigDecimal getTxnMinLimitAmount() {
return txnMinLimitAmount;
}
public BigDecimal getTxnMaxLimitAmount() {
return txnMaxLimitAmount;
}
} }
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/kyc/risk-rules}.
*
* Mirrors the columns the AML Rules Master screen displays. The {@code txn*} fields describe the
* live-transaction counterpart of the rule; supplying any of them creates a companion row in
* {@code m_aml_transaction_rule} linked back to the new {@code m_client_risk_rule} row. Leave them
* all blank for a pure KYC-time profile rule (income / turnover / occupation driven).
*/
@Getter
@Setter
@NoArgsConstructor
public class KycRiskRuleRequest {
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
private String ruleName;
@Schema(description = "Reason / criteria text explaining why the rule exists",
requiredMode = Schema.RequiredMode.REQUIRED)
private String reason;
@Schema(description = "Risk category the rule assigns: 1 Low, 2 Medium, 3 High",
requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
private Integer riskCategory;
@Schema(description = "Position in the catalog. Omit to append after the last existing rule.")
private Integer orderPosition;
@Schema(description = "Legal form the rule is restricted to (1 Person, 2 Entity). Null applies to all.")
private Integer legalFormId;
@Schema(description = "Occupation category code value the rule is restricted to")
private Long occupationCategoryCvId;
@Schema(description = "Occupation code value the rule is restricted to")
private Long occupationCvId;
private BigDecimal minMonthlyIncome;
private BigDecimal maxMonthlyIncome;
private BigDecimal minAnnualIncome;
private BigDecimal maxAnnualIncome;
private BigDecimal minAnnualTurnover;
private BigDecimal maxAnnualTurnover;
@Schema(description = "AML check status the rule records", example = "FLAGGED")
private String amlCheckStatus;
@Schema(description = "Banned entity match flag", example = "NO")
private String bannedEntityMatch;
@Schema(description = "Willful defaulter flag", example = "NO")
private String willfulDefaulter;
@Schema(description = "ECGC/SAL flag", example = "NO")
private String ecgcSalFlag;
@Schema(description = "Dormancy flag", example = "ACTIVE")
private String dormantFlag;
@Schema(description = "Lower bound of the transaction band the rule covers (display only)")
private BigDecimal txnMinLimitAmount;
@Schema(description = "Upper bound of the transaction band. Null means the rule is open-ended.")
private BigDecimal txnMaxLimitAmount;
@Schema(description = "Amount at which the rule fires on a live transaction. Defaults to the "
+ "lower bound of the band when omitted.")
private BigDecimal txnThresholdAmount;
@Schema(description = "Observation window in days. Omit or 0 for per-transaction.", example = "30")
private Integer txnWindowDays;
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
private String txnRuleType;
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
private String txnAppliesTo;
/**
* True when the officer has described a live-transaction rule rather than a pure KYC profile
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
*/
public boolean hasTransactionRule() {
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
}
}
@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_family_member_children")
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "family_member_id", nullable = false)
private ClientFamilyMembers familyMember;
@Column(name = "full_name", nullable = false)
private String fullName;
@ManyToOne
@JoinColumn(name = "gender_cv_id", nullable = false)
private CodeValue gender;
@Column(name = "date_of_birth", nullable = true)
private LocalDate dateOfBirth;
protected ClientFamilyMemberChildren() {
// Default constructor
}
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
final LocalDate dateOfBirth) {
this.familyMember = familyMember;
this.fullName = fullName;
this.gender = gender;
this.dateOfBirth = dateOfBirth;
}
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
final LocalDate dateOfBirth) {
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
}
public ClientFamilyMembers getFamilyMember() {
return this.familyMember;
}
public void setFamilyMember(ClientFamilyMembers familyMember) {
this.familyMember = familyMember;
}
public String getFullName() {
return this.fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public CodeValue getGender() {
return this.gender;
}
public void setGender(CodeValue gender) {
this.gender = gender;
}
public LocalDate getDateOfBirth() {
return this.dateOfBirth;
}
public void setDateOfBirth(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
}
}
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
@Column(name = "modified_on") @Column(name = "modified_on")
private LocalDateTime modifiedOn; private LocalDateTime modifiedOn;
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName, private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship, final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession, final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public Long getModifiedBy() { return modifiedBy; } public Long getModifiedBy() { return modifiedBy; }
public LocalDateTime getModifiedOn() { return modifiedOn; } public LocalDateTime getModifiedOn() { return modifiedOn; }
public java.util.List<ClientFamilyMemberChildren> getChildren() {
return this.children;
}
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
if (this.children == null) {
this.children = new java.util.ArrayList<>();
} else {
this.children.clear();
}
if (children != null) {
this.children.addAll(children);
}
}
} }
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "pending_lock_key", length = 60, unique = true) @Column(name = "pending_lock_key", length = 60, unique = true)
private String pendingLockKey; private String pendingLockKey;
// ── Email tracking ────────────────────────────────────────────────────────
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
@Column(name = "email_tracking_token", length = 36, unique = true)
private String emailTrackingToken;
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
@Column(name = "email_sent", nullable = false)
private Boolean emailSent = Boolean.FALSE;
/** Timestamp of the successful send. Null when emailSent is false. */
@Column(name = "email_sent_at")
private OffsetDateTime emailSentAt;
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
* the 1×1 img tag. Null until opened. */
@Column(name = "email_opened_at")
private OffsetDateTime emailOpenedAt;
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
* without regenerating the template. */
@Column(name = "email_subject", length = 300)
private String emailSubject;
/** Exact HTML body that was sent — stored alongside email_subject. */
@Column(name = "email_body", columnDefinition = "TEXT")
private String emailBody;
/** Exact sender address configured at the time the email was dispatched. */
@Column(name = "email_from", length = 255)
private String emailFrom;
// ── Factory / lifecycle ───────────────────────────────────────────────────
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson, public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
final AppUser maker) { final AppUser maker) {
final ClientPendingEdit edit = new ClientPendingEdit(); final ClientPendingEdit edit = new ClientPendingEdit();
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
edit.makerId = maker.getId(); edit.makerId = maker.getId();
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant(); edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
edit.pendingLockKey = lockKeyFor(client.getId()); edit.pendingLockKey = lockKeyFor(client.getId());
edit.emailSent = Boolean.FALSE;
return edit; return edit;
} }
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
this.rejectReason = reason; this.rejectReason = reason;
this.pendingLockKey = null; this.pendingLockKey = null;
} }
/** Called after a successful email send. Records the token (for pixel tracking), the exact
* subject and body, and marks the row as sent. */
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
this.emailTrackingToken = token;
this.emailSubject = subject;
this.emailBody = body;
this.emailFrom = fromAddress;
this.emailSent = Boolean.TRUE;
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
}
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
public void markEmailOpened() {
if (this.emailOpenedAt == null) {
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
}
}
} }
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status); Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status); List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
} }
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name()); return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
} }
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
return this.repository.findByEmailTrackingToken(token);
}
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) { public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
return this.repository.saveAndFlush(edit); return this.repository.saveAndFlush(edit);
} }
@@ -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,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER", "SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY"); "MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
public static final String CHILDREN = "children";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>( private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT, Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS, RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS, NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY)); AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
public static final String FAMILY_MEMBERS1 = "FamilyMembers"; public static final String FAMILY_MEMBERS1 = "FamilyMembers";
public static final String RELATION_SHIP_ID = "relationShipId"; public static final String RELATION_SHIP_ID = "relationShipId";
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
@@ -76,8 +76,16 @@ public class ClientKycCommandFromApiJsonDeserializer {
final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed( final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed(
ClientKycApiConstants.aadhaarOtpParam, element); 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, return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked,
gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin, 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; package org.apache.fineract.portfolio.client.service;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.client.domain.Client; 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.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/** /**
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>: * Generates CIF numbers in the format <code>CIF</code> followed by the client's id
* <ul> * zero-padded to 10 digits — e.g. client 123 → <code>CIF0000000123</code>.
* <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>
* *
* Example: an Individual client created in 2026 → <code>1120260000001</code>. * <p>
* The generator handles concurrent inserts by retrying on uniqueness collisions * Uniqueness is structural rather than probabilistic: <code>m_client.id</code> is a single sequence
* (the caller's outer save is what actually triggers the unique constraint). * 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 @Component
@RequiredArgsConstructor
public class CifNumberGenerator { public class CifNumberGenerator {
private static final String TYPE_INDIVIDUAL = "11"; private static final String CIF_PREFIX = "CIF";
private static final String TYPE_CORPORATE = "22";
private static final String TYPE_MINOR = "33";
private static final String TYPE_STAFF = "44";
private static final int SEQUENCE_WIDTH = 6; /** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */
private static final int MAX_COLLISION_RETRIES = 5; private static final int SEQUENCE_WIDTH = 10;
private final ClientRepository clientRepository;
/** /**
* 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) { public String generate(final Client client) {
final String typeCode = resolveTypeCode(client); final Long clientId = client.getId();
final int year = resolveYear(client); if (clientId == null) {
final String prefix = typeCode + year; throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted");
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++;
} }
// Last resort: append client id to guarantee uniqueness if every attempt collided return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0');
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();
} }
} }
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
/**
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
* their pending profile-edit. Also records the tracking token, subject and body back onto the
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
* dialog can reproduce it later without regenerating the template.
*
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
* that a mail-server outage never rolls back an already-committed approval.
*/
public interface ClientEditEmailService {
/**
* Attempts to send a confirmation email to the client. On success, calls
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
* {@code pendingEdit} — the approval itself is never rolled back.
*
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
* yet persisted by the caller at this point)
* @param client the client whose details were edited; used for display name and email
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
* {@code https://cbs.example.com}) used to build the tracking-pixel href
* @param tenantId the current tenant identifier, used to build the pixel URL
*/
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
}
@@ -0,0 +1,301 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
* Checker. Records the tracking token, exact subject and exact body back onto the
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
* the entity after this method returns).
*
* <p>Template selection:
* <ul>
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
* {@code original_json}). Lists old→new values in a table.</li>
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
* </ul>
*
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
* approval transaction to roll back. The caller catches any exception from this service and logs it.
*/
@Service
@RequiredArgsConstructor
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
private static final String BANK_NAME = "CBSNexora";
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
/** Contact fields that trigger Template A when they actually change. */
private static final String FIELD_MOBILE = "mobileNo";
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
private static final String FIELD_EMAIL = "emailAddress";
/** Human-readable labels for every field that can appear in the email body — mirrors the
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
private static final Map<String, String> FIELD_LABELS;
static {
FIELD_LABELS = new LinkedHashMap<>();
FIELD_LABELS.put("firstname", "First Name");
FIELD_LABELS.put("middlename", "Middle Name");
FIELD_LABELS.put("lastname", "Last Name");
FIELD_LABELS.put("fullname", "Full Name");
FIELD_LABELS.put("mobileNo", "Mobile Number");
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
FIELD_LABELS.put("emailAddress", "Email Address");
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
FIELD_LABELS.put("genderId", "Gender");
FIELD_LABELS.put("maritalStatusId", "Marital Status");
FIELD_LABELS.put("educationLevelId", "Education Level");
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
FIELD_LABELS.put("occupationId", "Occupation");
FIELD_LABELS.put("religionId", "Religion");
FIELD_LABELS.put("socialCategoryId", "Social Category");
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
FIELD_LABELS.put("nationalityId", "Nationality");
FIELD_LABELS.put("officeId", "Branch / Office");
FIELD_LABELS.put("staffId", "Relationship Manager");
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
FIELD_LABELS.put("legalFormId", "Legal Form");
FIELD_LABELS.put("salutationId", "Salutation");
}
private final EmailSenderService emailSenderService;
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
@Override
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
final String baseUrl, final String tenantId) {
final String toEmail = client.getEmailAddress();
if (toEmail == null || toEmail.isBlank()) {
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
client.getId(), pendingEdit.getId());
return;
}
try {
final String token = UUID.randomUUID().toString();
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
final boolean isContactChange = detectContactChange(payload, original);
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
final String subject;
final String body;
if (isContactChange) {
subject = "Your contact details have been updated — " + BANK_NAME;
body = buildContactChangeBody(client.getDisplayName(), payload, original,
checkedDateFormatted, pixelUrl);
} else {
subject = "Your profile details have been updated — " + BANK_NAME;
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
checkedDateFormatted, pixelUrl);
}
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
if (sent) {
String fromEmail = "noreply@cbsnexora.in";
try {
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
fromEmail = smtp.getFromEmail();
}
} catch (Exception e) {
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
}
pendingEdit.markEmailSent(token, subject, body, fromEmail);
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
} else {
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
}
} catch (final Exception e) {
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
pendingEdit.getId(), e.getMessage(), e);
}
}
// ── Template detection ────────────────────────────────────────────────────
/**
* Returns true if any of the three contact fields actually changed between
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
* falls back to false (Template B).
*/
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
if (original == null) {
return false;
}
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
final String payloadVal = stringOf(payload, field);
final String originalVal = stringOf(original, field);
// Only counts as a change when both sides have a value and they differ, OR when
// payload has a non-blank value and original didn't have the key at all.
if (payloadVal != null && !payloadVal.equals(originalVal)) {
return true;
}
}
return false;
}
// ── Template A ────────────────────────────────────────────────────────────
private String buildContactChangeBody(final String clientName, final JsonObject payload,
final JsonObject original, final String date, final String pixelUrl) {
final StringBuilder rows = new StringBuilder();
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
final String newVal = stringOf(payload, field);
final String oldVal = original != null ? stringOf(original, field) : null;
if (newVal == null) {
continue;
}
if (oldVal != null && oldVal.equals(newVal)) {
continue;
}
final String label = FIELD_LABELS.getOrDefault(field, field);
rows.append("<tr>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
.append(escHtml(newVal)).append("</td>")
.append("</tr>");
}
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
+ "<p>Dear " + escHtml(clientName) + ",</p>"
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
+ "<thead><tr>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
+ "</body></html>";
}
// ── Template B ────────────────────────────────────────────────────────────
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
final JsonObject original, final String date, final String pixelUrl) {
final StringBuilder items = new StringBuilder();
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
final String key = entry.getKey();
// Skip internal/non-display keys
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
continue;
}
final String payloadVal = stringOf(payload, key);
final String originalVal = original != null ? stringOf(original, key) : null;
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
continue;
}
final String label = FIELD_LABELS.getOrDefault(key, key);
items.append("<li>").append(escHtml(label)).append("</li>");
}
final String listHtml = items.length() > 0
? "<ul style='padding-left:20px;'>" + items + "</ul>"
: "<p><em>No specific fields identified.</em></p>";
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
+ "<p>Dear " + escHtml(clientName) + ",</p>"
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
+ "<p>The following fields were changed:</p>"
+ listHtml
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
+ "</body></html>";
}
// ── Helpers ───────────────────────────────────────────────────────────────
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
// without requiring a tenant header (it accepts the token and commits in the default
// datasource context). However we embed it as a query param so future multi-tenant
// support is easy to add without changing the stored URL.
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
}
private JsonObject parseJson(final String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
final JsonElement el = JsonParser.parseString(json);
return el.isJsonObject() ? el.getAsJsonObject() : null;
} catch (final Exception e) {
return null;
}
}
private String stringOf(final JsonObject obj, final String key) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
return null;
}
final JsonElement el = obj.get(key);
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
}
private String escHtml(final String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;");
}
}
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command); CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
CommandProcessingResult approveEdit(Long clientId); /**
* Approves the pending edit and, on success, attempts to send a confirmation email to the
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
* can embed an absolute tracking-pixel URL in the email body.
*
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
* @param tenantId current tenant identifier
*/
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
CommandProcessingResult rejectEdit(Long clientId, String rejectReason); CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
} }
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException; import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException; import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService { public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */ /** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues"; private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
private final ClientPendingEditRepositoryWrapper pendingEditRepository; private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final ClientWritePlatformService clientWritePlatformService; private final ClientWritePlatformService clientWritePlatformService;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final ClientEditEmailService clientEditEmailService;
@Transactional @Transactional
@Override @Override
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
@Transactional @Transactional
@Override @Override
public CommandProcessingResult approveEdit(final Long clientId) { public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
final AppUser checker = this.context.authenticatedUser(); final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId); final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
// Replay the staged JSON through the SAME write-service method a direct PUT would have // Replay the staged JSON through the SAME write-service method a direct PUT would have
// used, so every bit of Client's existing validation/change-detection logic runs exactly // used, so every bit of Client's existing validation/change-detection logic runs exactly
// as it would for an immediate edit — just deferred until a different user approves it. // as it would for an immediate edit — just deferred until a different user approves it.
final Client client = pendingEdit.getClient();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson()); final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper); final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand); final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
pendingEdit.approve(checker); pendingEdit.approve(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit); this.pendingEditRepository.saveAndFlush(pendingEdit);
// Send confirmation email — silently swallowed on any failure so the approval is never
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
// email tracking columns. If the send fails or the client has no email, this save is
// a no-op (email_sent remains false).
try {
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
} else {
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
}
} catch (final Exception e) {
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
pendingEdit.getId(), e);
}
this.pendingEditRepository.saveAndFlush(pendingEdit);
return result; return result;
} }
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper(); final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true"; final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
for (ClientFamilyMembersData fm : familyMembers) {
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
}
return familyMembers;
} }
@Override @Override
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper(); final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false "; final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
if (fm != null) {
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
}
return fm;
}
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
"c.gender_cv_id as genderId, cv.code_value as gender " +
"from m_family_member_children c " +
"left join m_code_value cv on c.gender_cv_id = cv.id " +
"where c.family_member_id = ?";
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
.id(rs.getLong("id"))
.fullName(rs.getString("fullName"))
.genderId(JdbcSupport.getLong(rs, "genderId"))
.gender(rs.getString("gender"))
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
.build();
}, familyMemberId);
} }
@Override @Override
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException; import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException; import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer; import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
} }
} }
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
if (childrenArray != null) {
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
for (JsonElement childElement : childrenArray) {
if (childElement.isJsonNull()) continue;
JsonObject childObj = childElement.getAsJsonObject();
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
LocalDate dob = null;
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
try {
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
} catch (DateTimeParseException e) {
LOG.warn("Could not parse dateOfBirth for child", e);
}
}
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
}
}
}
familyMember.setChildren(children);
}
@Override @Override
public void validateFamilyRequirementsForActivation(final Client client) { public void validateFamilyRequirementsForActivation(final Client client) {
if (client == null || client.getId() == null || client.getLegalForm() == null) { if (client == null || client.getId() == null || client.getLegalForm() == null) {
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId)); clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
} }
if (command.parameterExists("children")) {
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
}
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers); this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
// mothers (or one mother + an existing mother in the DB) fails fast on the offender. // mothers (or one mother + an existing mother in the DB) fails fast on the offender.
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null); validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
if (member.has("children") && !member.get("children").isJsonNull()) {
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
}
this.clientFamilyRepository.saveAndFlush(familyMember); this.clientFamilyRepository.saveAndFlush(familyMember);
} }
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null, validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
clientFamilyMember.getRelationship(), familyMemberId); clientFamilyMember.getRelationship(), familyMemberId);
if (command.parameterExists("children")) {
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
}
this.clientFamilyRepository.saveAndFlush(clientFamilyMember); this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
@@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService {
* Retrieve the KYC record for a client. * Retrieve the KYC record for a client.
*/ */
ClientKycData retrieveClientKycByClientId(Long clientId); 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; package org.apache.fineract.portfolio.client.service;
import lombok.RequiredArgsConstructor; 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.data.ClientKycData;
import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository; 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 ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue());
final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue()); final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue());
final String mobileNo = kyc.getClient().getMobileNo();
final String emailAddress = kyc.getClient().getEmailAddress();
return ClientKycData.builder() return ClientKycData.builder()
.id(kyc.getId()) .id(kyc.getId())
.clientId(kyc.getClient().getId()) .clientId(kyc.getClient().getId())
.clientDisplayName(kyc.getClient().getDisplayName()) .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()) .kycStatus(status.getValue())
.kycStatusCode(status.getCode()) .kycStatusCode(status.getCode())
.kycType(type.getValue()) .kycType(type.getValue())
@@ -74,14 +85,27 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus()) .panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
.panCategory(kyc.getPanCategory()) .panCategory(kyc.getPanCategory())
.panStatus(kyc.getPanStatus()) .panStatus(kyc.getPanStatus())
.panDeclarationTaken(kyc.getPanDeclarationTaken())
.panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields())
.panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan())
.panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan())
.panDeclarationRemarks(kyc.getPanDeclarationRemarks())
.panDeclarationTakenOn(kyc.getPanDeclarationTakenOn())
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked()) .aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
.aadhaarVerified(kyc.isAadhaarVerified()) .aadhaarVerified(kyc.isAadhaarVerified())
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn()) .aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
.aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken())
.aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId())
.aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate())
.aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks())
.aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn())
.gstinNumber(kyc.getGstinNumber()) .gstinNumber(kyc.getGstinNumber())
.gstinVerified(kyc.isGstinVerified()) .gstinVerified(kyc.isGstinVerified())
.gstinVerifiedOn(kyc.getGstinVerifiedOn()) .gstinVerifiedOn(kyc.getGstinVerifiedOn())
.enterprisePan(kyc.getEnterprisePan()) .enterprisePan(kyc.getEnterprisePan())
.cinNumber(kyc.getCinNumber()) .cinNumber(kyc.getCinNumber())
.cinVerified(kyc.isCinVerified())
.cinVerifiedOn(kyc.getCinVerifiedOn())
.incorporationDate(kyc.getIncorporationDate()) .incorporationDate(kyc.getIncorporationDate())
.kycDocumentName(kyc.getKycDocumentName()) .kycDocumentName(kyc.getKycDocumentName())
.kycDocumentNumber(kyc.getKycDocumentNumber()) .kycDocumentNumber(kyc.getKycDocumentNumber())
@@ -95,6 +119,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.accountStatus(kyc.getAccountStatus()) .accountStatus(kyc.getAccountStatus())
.freezeReason(kyc.getFreezeReason()) .freezeReason(kyc.getFreezeReason())
.freezeAuthority(kyc.getFreezeAuthority()) .freezeAuthority(kyc.getFreezeAuthority())
.freezeDate(kyc.getFreezeDate())
.freezeAmount(kyc.getFreezeAmount())
.unfreezeDate(kyc.getUnfreezeDate())
.noDebitDate(kyc.getNoDebitDate())
.form60Type(kyc.getForm60Type()) .form60Type(kyc.getForm60Type())
.form60Date(kyc.getForm60Date()) .form60Date(kyc.getForm60Date())
.form15Applicable(kyc.getForm15Applicable()) .form15Applicable(kyc.getForm15Applicable())
@@ -103,6 +131,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.ckycNumber(kyc.getCkycNumber()) .ckycNumber(kyc.getCkycNumber())
.ckycUploadedOn(kyc.getCkycUploadedOn()) .ckycUploadedOn(kyc.getCkycUploadedOn())
.ckycBatchId(kyc.getCkycBatchId()) .ckycBatchId(kyc.getCkycBatchId())
.isException(kyc.getIsException())
.exceptionReason(kyc.getExceptionReason())
.exceptionResolved(kyc.getExceptionResolved())
.exceptionResolvedOn(kyc.getExceptionResolvedOn())
.initiatedOn(kyc.getInitiatedOn()) .initiatedOn(kyc.getInitiatedOn())
.verifiedOn(kyc.getVerifiedOn()) .verifiedOn(kyc.getVerifiedOn())
.rejectedOn(kyc.getRejectedOn()) .rejectedOn(kyc.getRejectedOn())
@@ -110,4 +142,18 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.remarks(kyc.getRemarks()) .remarks(kyc.getRemarks())
.build(); .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());
}
} }
@@ -158,27 +158,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final Map<String, Object> changes = kyc.update(command); final Map<String, Object> changes = kyc.update(command);
if (!changes.isEmpty()) { 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); kycRepositoryWrapper.saveAndFlush(kyc);
} }
@@ -1009,23 +988,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
return; // no-op — edit-client sent the same KYC values that are already on the row 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); kycRepositoryWrapper.saveAndFlush(kyc);
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}", LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue()); client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import java.util.Map;
import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData; import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
*/ */
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset, Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
Integer limit); Integer limit);
/**
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
* edit — only populated once the approval email has been sent. Both {@code clientId} and
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
* when the record does not exist.
*/
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
} }
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName, pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
pe.submitted_date AS submittedDate, pe.submitted_date AS submittedDate,
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName, pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
pe.email_opened_at AS emailOpenedAt
FROM m_client_pending_edit pe FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id JOIN m_client c ON c.id = pe.client_id
JOIN m_appuser maker ON maker.id = pe.maker_id JOIN m_appuser maker ON maker.id = pe.maker_id
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> { private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
final boolean emailSent = rs.getBoolean("emailSent");
return ClientPendingEditData.builder() // return ClientPendingEditData.builder() //
.id(rs.getLong("id")) // .id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) // .clientId(rs.getLong("clientId")) //
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
.checkerName(rs.getString("checkerName")) // .checkerName(rs.getString("checkerName")) //
.checkedDate(checkedDate) // .checkedDate(checkedDate) //
.rejectReason(rs.getString("rejectReason")) // .rejectReason(rs.getString("rejectReason")) //
.emailSent(emailSent) //
.emailSentAt(emailSentAt) //
.emailOpenedAt(emailOpenedAt) //
.build(); .build();
}; };
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue()); return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
} }
@Override
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
final String sql = """
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
c.email_address AS clientEmail
FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id
WHERE pe.id = ? AND pe.client_id = ?
""";
try {
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("emailSubject", rs.getString("emailSubject"));
result.put("emailBody", rs.getString("emailBody"));
result.put("emailSent", rs.getBoolean("emailSent"));
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
result.put("emailFrom", rs.getString("emailFrom"));
result.put("emailTo", rs.getString("clientEmail"));
return result;
}, pendingEditId, clientId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
} }
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService; import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -136,6 +137,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final CifNumberGenerator cifNumberGenerator; private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService; private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService; private final ClientChangeNotificationService clientChangeNotificationService;
private final JdbcTemplate jdbcTemplate;
@Transactional @Transactional
@Override @Override
@@ -617,9 +619,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientRepository.saveAndFlush(newClient); this.clientRepository.saveAndFlush(newClient);
} }
// CIF Number generation — TTYYYYNNNNNN format. TT picked from client type // CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123.
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate, // Must run after the saveAndFlush above, since the id is what the number is derived from;
// NNNNNN is the next 6-digit sequence within the (type, year) bucket. // deriving it from the shared id sequence is what keeps CIFs unique across all branches.
if (StringUtils.isBlank(cifNumber)) { if (StringUtils.isBlank(cifNumber)) {
newClient.updateCifNumber(cifNumberGenerator.generate(newClient)); newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
this.clientRepository.saveAndFlush(newClient); this.clientRepository.saveAndFlush(newClient);
@@ -788,13 +790,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
* client onboarded without a mobile number is having one set for the first time) — there is no * client onboarded without a mobile number is having one set for the first time) — there is no
* existing verified channel to prove possession of in that case. * existing verified channel to prove possession of in that case.
*/ */
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue, private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
final String newValue) { final String newValue) {
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) { if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName); final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName); final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
if (StringUtils.isNotBlank(oldValue)) { if (StringUtils.isNotBlank(oldValue)) {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName); if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
final Integer docCount = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
Integer.class, oldMobileExceptionDocId, clientId);
if (docCount == null || docCount == 0) {
throw new PlatformDataIntegrityException(
"error.msg.client.mobile.old.verification.exception.invalid.document",
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
}
} else {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
}
} }
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName); this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) { } else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
@@ -862,7 +878,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) { if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName); final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String oldValue = clientForUpdate.getMobileNo(); final String oldValue = clientForUpdate.getMobileNo();
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue); verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
changes.put(ClientApiConstants.mobileNoParamName, newValue); changes.put(ClientApiConstants.mobileNoParamName, newValue);
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null)); clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
oldMobileNoForNotification = oldValue; oldMobileNoForNotification = oldValue;
@@ -879,7 +895,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) { if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName); final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
final String oldValue = clientForUpdate.getEmailAddress(); final String oldValue = clientForUpdate.getEmailAddress();
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue); verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
changes.put(ClientApiConstants.emailAddressParamName, newValue); changes.put(ClientApiConstants.emailAddressParamName, newValue);
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null)); clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
oldEmailAddressForNotification = oldValue; oldEmailAddressForNotification = oldValue;
@@ -1450,7 +1466,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true); final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
validateParentGroupRulesBeforeClientActivation(client); 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 Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate"); final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
@@ -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 { public interface KycRiskRulesReadPlatformService {
List<KycRiskRuleData> retrieveAllRules(); List<KycRiskRuleData> retrieveAllRules();
/** One rule with its live-transaction counterpart resolved, as returned after a create. */
KycRiskRuleData retrieveRule(Long ruleId);
} }
@@ -19,8 +19,12 @@
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData; import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule; import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository; import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
@@ -33,14 +37,27 @@ import org.springframework.transaction.annotation.Transactional;
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService { public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
private final ClientRiskRuleRepository repository; private final ClientRiskRuleRepository repository;
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
@Override @Override
public List<KycRiskRuleData> retrieveAllRules() { public List<KycRiskRuleData> retrieveAllRules() {
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition(); final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
return rules.stream().map(this::mapToData).collect(Collectors.toList()); final Map<Long, AmlTransactionRule> txnRulesBySourceId = this.amlTransactionRuleRepository.findAll().stream()
.filter(txn -> txn.getSourceClientRiskRuleId() != null)
.collect(Collectors.toMap(AmlTransactionRule::getSourceClientRiskRuleId, Function.identity(), (a, b) -> a));
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
} }
private KycRiskRuleData mapToData(final ClientRiskRule rule) { @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 Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null; final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null; final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
@@ -67,7 +84,14 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
rule.getBannedEntityMatch(), rule.getBannedEntityMatch(),
rule.getWillfulDefaulter(), rule.getWillfulDefaulter(),
rule.getEcgcSalFlag(), rule.getEcgcSalFlag(),
rule.getDormantFlag() rule.getDormantFlag(),
txnRule != null ? txnRule.getThresholdAmount() : null,
txnRule != null ? txnRule.getRuleType().name() : null,
txnRule != null ? txnRule.getWindowDays() : null,
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
txnRule != null ? txnRule.getMinLimitAmount() : null,
txnRule != null ? txnRule.getMaxLimitAmount() : 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);
}
@@ -0,0 +1,162 @@
/**
* 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.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePlatformService {
private static final String RESOURCE = "kycriskrule";
private final ClientRiskRuleRepository repository;
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
private final CodeValueRepositoryWrapper codeValueRepository;
private final KycRiskRulesReadPlatformService readPlatformService;
@Override
@Transactional
public KycRiskRuleData createRule(final KycRiskRuleRequest request) {
validate(request);
// Appending is the safe default: order_position drives evaluation precedence, so silently
// inserting into the middle of the catalog would re-prioritise existing rules.
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
request.getMinAnnualTurnover(), request.getMaxAnnualTurnover(),
StringUtils.defaultIfBlank(request.getAmlCheckStatus(), "CLEAR"),
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"));
if (request.getOccupationCategoryCvId() != null) {
final CodeValue occupationCategory = this.codeValueRepository
.findOneWithNotFoundDetection(request.getOccupationCategoryCvId());
rule.setOccupationCategory(occupationCategory);
}
if (request.getOccupationCvId() != null) {
final CodeValue occupation = this.codeValueRepository.findOneWithNotFoundDetection(request.getOccupationCvId());
rule.setOccupation(occupation);
}
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
if (request.hasTransactionRule()) {
this.amlTransactionRuleRepository.save(buildTransactionRule(saved, request));
}
return this.readPlatformService.retrieveRule(saved.getId());
}
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
// The band's lower bound is what the rule fires at unless an explicit threshold is given,
// so a rule described purely as a band is still evaluable against live transactions.
final BigDecimal threshold = request.getTxnThresholdAmount() != null ? request.getTxnThresholdAmount()
: request.getTxnMinLimitAmount();
final AmlRuleType ruleType = StringUtils.isBlank(request.getTxnRuleType()) ? AmlRuleType.THRESHOLD
: AmlRuleType.valueOf(request.getTxnRuleType().trim().toUpperCase());
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
}
private Integer nextOrderPosition() {
final Integer max = this.repository.findMaxOrderPosition();
return max == null ? 1 : max + 1;
}
private void validate(final KycRiskRuleRequest request) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
.integerGreaterThanZero();
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
.integerZeroOrGreater();
validator.reset().parameter("txnMinLimitAmount").value(request.getTxnMinLimitAmount()).ignoreIfNull()
.zeroOrPositiveAmount();
validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount()).ignoreIfNull()
.zeroOrPositiveAmount();
validator.reset().parameter("txnThresholdAmount").value(request.getTxnThresholdAmount()).ignoreIfNull()
.zeroOrPositiveAmount();
// A band whose ceiling sits under its floor would silently never match a transaction.
if (request.getTxnMinLimitAmount() != null && request.getTxnMaxLimitAmount() != null
&& request.getTxnMaxLimitAmount().compareTo(request.getTxnMinLimitAmount()) < 0) {
validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount())
.failWithCode("must.be.greater.than.min.limit");
}
if (StringUtils.isNotBlank(request.getRuleName()) && this.repository.existsByRuleNameIgnoreCase(request.getRuleName().trim())) {
validator.reset().parameter("ruleName").value(request.getRuleName()).failWithCode("duplicate");
}
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
}
/** Guards the enum conversions in {@link #buildTransactionRule}, which would otherwise throw a
* bare IllegalArgumentException and surface to the officer as a 500. */
private <E extends Enum<E>> void validateEnum(final DataValidatorBuilder validator, final String parameter,
final String value, final Class<E> type) {
if (StringUtils.isBlank(value)) {
return;
}
final String candidate = value.trim().toUpperCase();
for (final E constant : type.getEnumConstants()) {
if (constant.name().equals(candidate)) {
return;
}
}
validator.reset().parameter(parameter).value(value).failWithCode("is.not.supported");
}
}
@@ -93,6 +93,15 @@ public class SanctionsXmlLoaderService {
} }
} }
// Replace the previous list rather than adding to it. A consolidated-list XML is a full
// snapshot, so appending would both duplicate every entry that is still listed and leave
// de-listed individuals active forever — screening has to reflect the current list only.
// Done after parsing has fully succeeded, so a malformed file cannot leave the table empty.
final long removed = sanctionsEntryRepository.countByActiveTrue();
sanctionsEntryRepository.deleteAllByActiveTrue();
sanctionsEntryRepository.flush();
LOG.info("Removed {} previously loaded sanctions entries before loading the new snapshot", removed);
// Save in batches // Save in batches
final int batchSize = 100; final int batchSize = 100;
for (int i = 0; i < allEntries.size(); i += batchSize) { for (int i = 0; i < allEntries.size(); i += batchSize) {
@@ -647,7 +647,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
} }
private boolean isWithHoldTaxApplicableForInterestPosting(final SavingsAccountData savingsAccountData) { private boolean isWithHoldTaxApplicableForInterestPosting(final SavingsAccountData savingsAccountData) {
return this.withHoldTax(savingsAccountData) && this.depositAccountType(savingsAccountData).isSavingsDeposit(); return false;
} }
private boolean withHoldTax(final SavingsAccountData savingsAccountData) { private boolean withHoldTax(final SavingsAccountData savingsAccountData) {
@@ -67,6 +67,10 @@ public class ShareAccountData implements Serializable, AccountData {
private Long savingsAccountId; private Long savingsAccountId;
private Long linkedLoanId;
private String linkedLoanAccountNo;
private BigDecimal currentMarketPrice; private BigDecimal currentMarketPrice;
private Integer lockinPeriod; private Integer lockinPeriod;
@@ -301,6 +305,11 @@ public class ShareAccountData implements Serializable, AccountData {
this.currentMarketPrice = currentMarketPrice; this.currentMarketPrice = currentMarketPrice;
} }
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
this.linkedLoanId = linkedLoanId;
this.linkedLoanAccountNo = linkedLoanAccountNo;
}
public void setDividends(Collection<ShareAccountDividendData> dividends) { public void setDividends(Collection<ShareAccountDividendData> dividends) {
this.dividends = dividends; this.dividends = dividends;
} }
@@ -51,4 +51,10 @@ public class ShareAccountTransactionData implements Serializable {
private final BigDecimal amountPaid; private final BigDecimal amountPaid;
/** Board Resolution Number / Date of Meeting that authorised this transaction — populated
* only for a redeem (withdrawal) transaction; null otherwise. */
private final String resolutionNumber;
private final LocalDate meetingDate;
} }

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