Compare commits

..
16 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
nitesh 1a9adaec24 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-05 18:34:33 +05:30
niteshandClaude Sonnet 5 2da5a25e04 feat(shares): add compliance and loan/guarantor/tenure checks to validation
The /validate endpoint now re-checks live state that matters for a real
UCB share redemption: Client KYC verified, Risk Category not High, no open
AML alerts, and — since this bank supports loan-against-shares via
m_share_account.linked_loan_id — a Loan Check (no active loan secured by
these shares), Tenure Check (linked loan's term completed), and Guarantor
Check (client not guaranteeing someone else's active loan). Dropped the
redundant "status is PENDING" check and replaced raw status_enum codes
with human-readable labels throughout. getById() also now returns a
client compliance summary so the review screen can show it without
running the popup validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:33:12 +05:30
keshav baf30cff68 kyc exception at client creation time and some tds work
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-05 17:27:56 +05:30
niteshandClaude Sonnet 5 f1d68d17fe feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
New ClerkShareWithdrawalRequestApiResource backs a clerk-submits/manager-
approves flow for share redemption: on submit, validates and persists the
clerk-chosen settlement Payment Type (must be non-cash) plus a full audit
snapshot of the settlement row; on approve, executes the real "redeemshares"
command and, in the same transaction, credits the redemption value to the
share account's linked savings account using that stored payment type (with
a hardcoded-lookup fallback for pre-existing rows). Adds a /validate endpoint
that re-checks live account/GL state (not just the submit-time snapshot) so
a manager can verify before approving. Also exposes linked-savings-account
data on ShareAccount reads so the frontend can display/validate it.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:53:27 +05:30
115 changed files with 5599 additions and 4585 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ gitVersioning.apply {
}
ext['groovy.version'] = '4.0.17'
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
// Apply static weaving configuration to all subprojects
subprojects { subproject ->
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
+1 -1
View File
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
// Configure Java compilation
tasks.named('compileJava') {
outputs.cacheIf { true }
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
}
@@ -53,6 +53,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -36,6 +36,9 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
long countByClientId(Long clientId);
/** Alerts for one customer inside the recurring-behaviour observation window. */
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
/**
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
*
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
*/
@Entity
@Table(name = "m_aml_recurring_review")
public class AmlRecurringReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "alert_count_at_review", nullable = false)
private Integer alertCountAtReview;
@Column(name = "outcome", nullable = false, length = 30)
private String outcome;
@Column(name = "notes", nullable = false, length = 1000)
private String notes;
@ManyToOne(optional = false)
@JoinColumn(name = "reviewed_by", nullable = false)
private AppUser reviewedBy;
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
final AmlRecurringReview review = new AmlRecurringReview();
review.client = client;
review.alertCountAtReview = alertCountAtReview;
review.outcome = outcome.name();
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
return review;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
public Integer getAlertCountAtReview() {
return alertCountAtReview;
}
public AmlRecurringReviewOutcome getOutcome() {
return AmlRecurringReviewOutcome.valueOf(outcome);
}
public String getNotes() {
return notes;
}
public AppUser getReviewedBy() {
return reviewedBy;
}
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** What a reviewer concluded about a customer's repeated AML activity. */
public enum AmlRecurringReviewOutcome {
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
CLEARED,
/** Not conclusive; keep watching this customer more closely. */
MONITOR,
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
ESCALATED
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
/** Newest first, so the caller can take the head as the latest review for a customer. */
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
}
@@ -86,6 +86,29 @@ public class AmlTransactionRule {
protected AmlTransactionRule() {}
/**
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
* the two rows are always created together and the rule code is derived from it.
*/
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
final AmlTransactionRule rule = new AmlTransactionRule();
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
rule.description = description;
rule.appliesTo = appliesTo.name();
rule.ruleType = ruleType.name();
rule.windowDays = windowDays;
rule.thresholdAmount = thresholdAmount;
rule.minLimitAmount = minLimitAmount;
rule.maxLimitAmount = maxLimitAmount;
rule.active = true;
return rule;
}
public Long getId() {
return id;
}
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "is_pwd")
private Boolean isPwd;
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
private ClientKyc clientKyc;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pwd_category_cv_id")
private CodeValue pwdCategory;
@@ -27,6 +27,7 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@@ -103,6 +104,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "pan_status", length = 20)
private String panStatus;
// ── PAN declaration ──────────────────────────────────────────────────────
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
// never clear it however many times the officer retries. Rather than leave such a client
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
@Column(name = "pan_declaration_taken")
private Boolean panDeclarationTaken;
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
@Column(name = "pan_declaration_mismatch_fields", length = 100)
private String panDeclarationMismatchFields;
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
@Column(name = "pan_declaration_name_on_pan", length = 200)
private String panDeclarationNameOnPan;
/** Date of birth exactly as printed on the physical PAN card. */
@Column(name = "pan_declaration_dob_on_pan")
private LocalDate panDeclarationDobOnPan;
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
@Column(name = "pan_declaration_remarks", length = 500)
private String panDeclarationRemarks;
@Column(name = "pan_declaration_taken_on")
private LocalDate panDeclarationTakenOn;
// ── Aadhaar ──────────────────────────────────────────────────────────────
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
@Column(name = "aadhaar_number_masked", length = 12)
@@ -114,6 +145,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "aadhaar_verified_on")
private LocalDate aadhaarVerifiedOn;
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
// itself is issued there is no number to put through the OTP flow — the check cannot pass
// however long the branch waits. Recording the receipt is what waives the exception.
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
@Column(name = "aadhaar_receipt_taken")
private Boolean aadhaarReceiptTaken;
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
@Column(name = "aadhaar_enrolment_id", length = 40)
private String aadhaarEnrolmentId;
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
@Column(name = "aadhaar_enrolment_date")
private LocalDate aadhaarEnrolmentDate;
/** Officer's note on what was sighted. */
@Column(name = "aadhaar_receipt_remarks", length = 500)
private String aadhaarReceiptRemarks;
@Column(name = "aadhaar_receipt_taken_on")
private LocalDate aadhaarReceiptTakenOn;
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
@Column(name = "gstin_number", length = 15)
private String gstinNumber;
@@ -195,6 +251,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "freeze_authority", length = 100)
private String freezeAuthority;
@Column(name = "freeze_date")
private LocalDate freezeDate;
@Column(name = "freeze_amount", precision = 19, scale = 6)
private BigDecimal freezeAmount;
@Column(name = "unfreeze_date")
private LocalDate unfreezeDate;
@Column(name = "no_debit_date")
private LocalDate noDebitDate;
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
@Column(name = "form60_type", length = 20)
private String form60Type;
@@ -241,6 +309,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "is_exception", nullable = false)
private Boolean isException;
@Column(name = "exception_reason", length = 200)
private String exceptionReason;
@Column(name = "exception_resolved", nullable = false)
private Boolean exceptionResolved;
@Column(name = "exception_resolved_on")
private LocalDate exceptionResolvedOn;
// ─────────────────────────────────────────────────────────────────────────
protected ClientKyc() {}
@@ -261,6 +341,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.gstinVerified = false;
this.cinVerified = false;
this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant();
}
@@ -288,6 +370,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
private void applyExtendedFields(final JsonCommand command) {
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
this.enterprisePan = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("enterprisePan"), null);
this.cinNumber = StringUtils.defaultIfEmpty(
@@ -317,6 +417,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("freezeReason"), null);
this.freezeAuthority = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("freezeAuthority"), null);
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
this.form60Type = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form60Type"), null);
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
@@ -325,6 +429,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("form15Type"), null);
this.form15Fy = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form15FY"), null);
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
this.isException = isExceptionVal != null ? isExceptionVal : false;
this.exceptionReason = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("exceptionReason"), null);
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
}
public Map<String, Object> update(final JsonCommand command) {
@@ -367,6 +478,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
v -> this.panAadhaarSeedingStatus = v);
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
v -> this.panDeclarationTaken = v != null ? v : false);
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
v -> this.panDeclarationMismatchFields = v);
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
v -> this.panDeclarationNameOnPan = v);
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
v -> this.panDeclarationDobOnPan = v);
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
v -> this.panDeclarationRemarks = v);
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
v -> this.panDeclarationTakenOn = v);
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
v -> this.aadhaarReceiptTaken = v != null ? v : false);
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
v -> this.aadhaarEnrolmentId = v);
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
v -> this.aadhaarEnrolmentDate = v);
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
v -> this.aadhaarReceiptRemarks = v);
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
v -> this.aadhaarReceiptTakenOn = v);
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
@@ -392,12 +525,23 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
v -> this.freezeAuthority = v);
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
return actualChanges;
}
@@ -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. */
public void markInProgress() {
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
@@ -631,6 +784,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return panStatus;
}
public Boolean getPanDeclarationTaken() {
return panDeclarationTaken;
}
/** Null-safe form for callers deciding whether the PAN exception is waived. */
public boolean isPanDeclarationTaken() {
return Boolean.TRUE.equals(this.panDeclarationTaken);
}
public String getPanDeclarationMismatchFields() {
return panDeclarationMismatchFields;
}
public String getPanDeclarationNameOnPan() {
return panDeclarationNameOnPan;
}
public LocalDate getPanDeclarationDobOnPan() {
return panDeclarationDobOnPan;
}
public String getPanDeclarationRemarks() {
return panDeclarationRemarks;
}
public LocalDate getPanDeclarationTakenOn() {
return panDeclarationTakenOn;
}
public Boolean getAadhaarReceiptTaken() {
return aadhaarReceiptTaken;
}
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
public boolean isAadhaarReceiptTaken() {
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
}
public String getAadhaarEnrolmentId() {
return aadhaarEnrolmentId;
}
public LocalDate getAadhaarEnrolmentDate() {
return aadhaarEnrolmentDate;
}
public String getAadhaarReceiptRemarks() {
return aadhaarReceiptRemarks;
}
public LocalDate getAadhaarReceiptTakenOn() {
return aadhaarReceiptTakenOn;
}
public String getAadhaarNumberMasked() {
return aadhaarNumberMasked;
}
@@ -795,6 +1002,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return form15Fy;
}
public Boolean getIsException() {
return isException;
}
public void setIsException(Boolean isException) {
this.isException = isException;
}
public String getExceptionReason() {
return exceptionReason;
}
public void setExceptionReason(String exceptionReason) {
this.exceptionReason = exceptionReason;
}
public Boolean getExceptionResolved() {
return exceptionResolved;
}
public void setExceptionResolved(Boolean exceptionResolved) {
this.exceptionResolved = exceptionResolved;
}
public LocalDate getExceptionResolvedOn() {
return exceptionResolvedOn;
}
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
this.exceptionResolvedOn = exceptionResolvedOn;
}
@Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE;
@@ -846,4 +1085,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public Long getVersion() {
return this.version;
}
public LocalDate getFreezeDate() {
return this.freezeDate;
}
public BigDecimal getFreezeAmount() {
return this.freezeAmount;
}
public LocalDate getUnfreezeDate() {
return this.unfreezeDate;
}
public LocalDate getNoDebitDate() {
return this.noDebitDate;
}
}
@@ -0,0 +1,204 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
/**
* One customer-outreach attempt against an open KYC exception.
*
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
* Master Direction expects the bank to be able to show what it told the customer and when.
*
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
* browser.
*/
@Entity
@Table(name = "m_client_kyc_contact_log")
public class ClientKycContactLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
@Column(name = "kyc_id")
private Long kycId;
@Enumerated(EnumType.STRING)
@Column(name = "channel", length = 20, nullable = false)
private KycContactChannel channel;
@Column(name = "notes", length = 1000, nullable = false)
private String notes;
@Column(name = "officer_name", length = 200)
private String officerName;
@Column(name = "logged_by_user_id")
private Long loggedByUserId;
@Column(name = "exception_reason", length = 200)
private String exceptionReason;
@Enumerated(EnumType.STRING)
@Column(name = "delivery_status", length = 30, nullable = false)
private KycContactDeliveryStatus deliveryStatus;
@Column(name = "destination_masked", length = 120)
private String destinationMasked;
@Column(name = "provider", length = 40)
private String provider;
@Column(name = "provider_reference", length = 120)
private String providerReference;
@Column(name = "failure_reason", length = 500)
private String failureReason;
@Column(name = "message_body", length = 2000)
private String messageBody;
@Column(name = "contacted_at", nullable = false)
private LocalDateTime contactedAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
protected ClientKycContactLog() {
// JPA
}
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
this.client = client;
this.kycId = kycId;
this.channel = channel;
this.notes = notes;
this.officerName = officerName;
this.loggedByUserId = loggedByUserId;
this.exceptionReason = exceptionReason;
this.contactedAt = contactedAt;
this.createdAt = contactedAt;
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
}
/**
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
* persisted — the entity is append-only afterwards.
*/
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
final String providerReference, final String failureReason, final String messageBody) {
this.deliveryStatus = status;
this.destinationMasked = destinationMasked;
this.provider = provider;
this.providerReference = providerReference;
this.failureReason = truncate(failureReason, 500);
this.messageBody = truncate(messageBody, 2000);
}
private static String truncate(final String value, final int max) {
if (value == null || value.length() <= max) {
return value;
}
return value.substring(0, max - 3) + "...";
}
public Long getId() {
return this.id;
}
public Client getClient() {
return this.client;
}
public Long getKycId() {
return this.kycId;
}
public KycContactChannel getChannel() {
return this.channel;
}
public String getNotes() {
return this.notes;
}
public String getOfficerName() {
return this.officerName;
}
public Long getLoggedByUserId() {
return this.loggedByUserId;
}
public String getExceptionReason() {
return this.exceptionReason;
}
public KycContactDeliveryStatus getDeliveryStatus() {
return this.deliveryStatus;
}
public String getDestinationMasked() {
return this.destinationMasked;
}
public String getProvider() {
return this.provider;
}
public String getProviderReference() {
return this.providerReference;
}
public String getFailureReason() {
return this.failureReason;
}
public String getMessageBody() {
return this.messageBody;
}
public LocalDateTime getContactedAt() {
return this.contactedAt;
}
public LocalDateTime getCreatedAt() {
return this.createdAt;
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
/**
* Every contact log for the given clients in one round trip, so the exceptions grid can render
* "last contacted" for the whole page without an N+1 query per row.
*/
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
}
@@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
boolean existsByClientId(Long clientId);
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
/**
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
*
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
* closed one would be wrong. An earlier version of this query required both flags, which made
* every resolved record invisible in both tabs the moment it was resolved.
*/
List<ClientKyc> findByExceptionResolvedTrue();
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
void removeSignatureId(@Param("clientId") Long clientId);
/**
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
*/
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
long countByCifNumberPrefix(@Param("prefix") String prefix);
// ── Pre-save uniqueness probes ───────────────────────────────────────────
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
@@ -95,6 +95,44 @@ public class ClientRiskRule {
protected ClientRiskRule() {}
/**
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
* are attached separately by the caller, which owns the CodeValue lookup.
*/
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
final String dormantFlag) {
final ClientRiskRule rule = new ClientRiskRule();
rule.ruleName = ruleName;
rule.reason = reason;
rule.riskCategory = riskCategory;
rule.orderPosition = orderPosition;
rule.legalFormId = legalFormId;
rule.minMonthlyIncome = minMonthlyIncome;
rule.maxMonthlyIncome = maxMonthlyIncome;
rule.minAnnualIncome = minAnnualIncome;
rule.maxAnnualIncome = maxAnnualIncome;
rule.minAnnualTurnover = minAnnualTurnover;
rule.maxAnnualTurnover = maxAnnualTurnover;
rule.amlCheckStatus = amlCheckStatus;
rule.bannedEntityMatch = bannedEntityMatch;
rule.willfulDefaulter = willfulDefaulter;
rule.ecgcSalFlag = ecgcSalFlag;
rule.dormantFlag = dormantFlag;
return rule;
}
public void setOccupationCategory(final CodeValue occupationCategory) {
this.occupationCategory = occupationCategory;
}
public void setOccupation(final CodeValue occupation) {
this.occupation = occupation;
}
public Long getId() {
return id;
}
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
List<ClientRiskRule> findAllOrderedByOrderPosition();
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
Integer findMaxOrderPosition();
boolean existsByRuleNameIgnoreCase(String ruleName);
}
@@ -0,0 +1,91 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/**
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
* KYC exception.
*
* <p>The critical distinction is {@link #isPlatformSent()}:
* <ul>
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
* recorded after the fact — the officer already did the thing, the platform only writes it
* down.</li>
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
* actually transmit a notice to the customer at save time.</li>
* </ul>
* Mixing these up would let a "logged" row imply the customer was told something they never
* received, so every call site branches on this flag rather than on the channel name.
*/
public enum KycContactChannel {
PHONE_CALL("Phone Call", false),
SMS("SMS", true),
EMAIL("Email", true),
WHATSAPP("WhatsApp Message", true),
BRANCH_VISIT("In-Branch Visit", false),
FIELD_VISIT("Agent Field Visit", false);
private final String displayLabel;
private final boolean platformSent;
KycContactChannel(final String displayLabel, final boolean platformSent) {
this.displayLabel = displayLabel;
this.platformSent = platformSent;
}
public String getDisplayLabel() {
return this.displayLabel;
}
/** True when saving a log of this channel must actually transmit a message to the customer. */
public boolean isPlatformSent() {
return this.platformSent;
}
/**
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
* labels describe the *purpose* of the message, not a different channel.
*
* @return the matching channel, or {@code null} when nothing matches.
*/
public static KycContactChannel fromString(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String normalised = raw.trim().toUpperCase();
for (final KycContactChannel channel : values()) {
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
return channel;
}
}
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
KycContactChannel best = null;
for (final KycContactChannel channel : values()) {
final String label = channel.displayLabel.toUpperCase();
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
best = channel;
}
}
return best;
}
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/**
* Outcome of a platform-sent KYC contact notice.
*
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
* failure would understate the bank's outreach obligation during a KYC audit.
*/
public enum KycContactDeliveryStatus {
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
NOT_APPLICABLE,
/** The gateway accepted the message for delivery. */
SENT,
/** The gateway was reached but rejected the message, or the call errored. */
FAILED,
/** The customer has no mobile number / email address on file for this channel. */
SKIPPED_NO_DESTINATION,
/** No gateway is configured for this channel in this deployment. */
PROVIDER_NOT_CONFIGURED;
public boolean isDelivered() {
return this == SENT;
}
}
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
@Service
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
private static final int MAX_CAUSE_DEPTH = 10;
@Deprecated
private final List<EntityImageIdAdapter> imageIdAdapters;
private final ContentStoreService storeService;
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) {
if (isMissingFromContentStore(e)) {
// The m_image row survives but the file it points at does not — the usual cause is a
// database copied between environments without its file store. For the caller that is
// simply "this entity has no image", not a server fault, so report it the same way as a
// missing image record instead of letting it surface as a 500.
log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType,
entityId);
throw new DocumentNotFoundException(entityType, entityId, -1L);
}
throw new DocumentInvalidRequestException(e);
}
}
/**
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
* way up (the file store rethrows as ContentStoreException).
*/
private boolean isMissingFromContentStore(final Throwable failure) {
Throwable cause = failure;
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
return true;
}
cause = cause.getCause();
}
return false;
}
}
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -102,12 +102,23 @@ public interface ShareAccountApiConstants {
String shareEntityType = "share";
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
String redeemresolutionnumber_paramname = "resolutionNumber";
String redeemmeetingdate_paramname = "meetingDate";
/** Loan this share account is held as margin/security against — optional, set via the
* account update command. When present, a redeem request is checked against the loan's
* outstanding balance. */
String linkedloanid_paramname = "linkedLoanId";
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
numberofshares_paramname, purchasedprice_paramname)));
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
}
@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/recurring-alerts")
@Component
@Tag(name = "AML Recurring Alerts",
description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.")
@RequiredArgsConstructor
public class AmlRecurringAlertApiResource {
private final PlatformSecurityContext context;
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final AmlRecurringAlertWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<AmlRecurringAlertData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the recurring-alert review queue",
description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. "
+ "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.")
public String retrieveRecurringAlerts(
@QueryParam("minAlerts") @DefaultValue("3") final int minAlerts,
@QueryParam("windowDays") @DefaultValue("90") final int windowDays,
@QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) {
this.context.authenticatedUser();
final List<AmlRecurringAlertData> rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1),
onlyNeedingReview);
return this.toApiJsonSerializer.serialize(rows);
}
@POST
@Path("{clientId}/review")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a review of a customer's repeated AML activity",
description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert "
+ "count grows beyond the count reviewed here.")
public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(),
request.getWindowDays() == null ? 0 : request.getWindowDays());
return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}";
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */
@Getter
@Setter
@NoArgsConstructor
public class AmlRecurringReviewRequest {
@Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR")
private String outcome;
@Schema(description = "Why the customer keeps surfacing and what was decided",
requiredMode = Schema.RequiredMode.REQUIRED)
private String notes;
@Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it",
example = "90")
private Integer windowDays;
}
@@ -22,7 +22,14 @@ import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* @param riskCategory
* severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating
* {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog
* ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity.
*/
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable {
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
implements Serializable {
}
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* One customer who has tripped the AML engine repeatedly inside the observation window.
*
* @param alertCount
* alerts in the window — what the threshold is compared against
* @param highRiskCount
* how many of those came from a High severity catalog rule
* @param distinctRules
* number of different rules involved; one rule firing five times is a weaker signal than
* five different rules firing once, and the reviewer needs to see which it is
* @param openCount
* alerts still awaiting a per-transaction disposition
* @param escalatedCount
* alerts already escalated to an STR case
* @param needsReview
* true when there is no review yet, or new alerts have arrived since the last one
* @param alerts
* the triggering transactions themselves, so the reviewer can see why this customer keeps
* surfacing without leaving the row
*/
public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory,
int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount,
LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List<String> ruleCodes, boolean needsReview,
Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn,
String lastReviewNotes, LocalDate windowFrom, List<AmlAlertData> alerts) implements Serializable {
}
@@ -19,12 +19,15 @@
package org.apache.fineract.portfolio.aml.service;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
private final AmlAlertRepository repository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
.collect(Collectors.toList());
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
// lookup inside the map step would re-read the same catalog rows repeatedly.
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
}
private AmlAlertData mapToData(AmlAlert alert) {
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
.toList();
final Map<Long, Integer> byId = new HashMap<>();
if (sourceIds.isEmpty()) {
return byId;
}
this.clientRiskRuleRepository.findAllById(sourceIds).forEach(rule -> byId.put(rule.getId(), rule.getRiskCategory()));
return byId;
}
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import org.apache.fineract.portfolio.client.domain.Client;
public interface AmlRecurringAlertNotificationService {
/**
* Called once per transaction after the engine has saved its alerts. Raises a notification for
* AML reviewers when this customer's alert count crosses the recurring-behaviour threshold.
*
* @param alertsJustRaised
* how many alerts this transaction produced, so the count before it can be derived
*/
void onAlertsRaised(Client client, int alertsJustRaised);
}
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.notification.service.UserNotificationService;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.springframework.stereotype.Service;
/**
* Pushes a notification to AML reviewers when one customer keeps tripping the engine.
*
* The review queue on the AML Rules Master screen is the durable record; this is the nudge that
* stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the
* customer's office, resolved by {@link UserNotificationService}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService {
/**
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already
* treats as "this customer has done this enough times to warrant another look".
*/
private static final int RECURRING_THRESHOLD = 3;
private static final int WINDOW_DAYS = 90;
private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT";
private static final String OBJECT_TYPE = "AML_RECURRING";
private static final String EVENT_TYPE = "recurringAmlActivity";
private final AmlAlertRepository alertRepository;
private final UserNotificationService userNotificationService;
@Override
public void onAlertsRaised(final Client client, final int alertsJustRaised) {
if (client == null || alertsJustRaised <= 0) {
return;
}
try {
final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay();
final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since);
final long before = after - alertsJustRaised;
// Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every
// alert once the threshold is passed. Notifying every time would bury the reviewer in
// duplicates for a customer they have already been told about; notifying only on the
// first crossing would go permanently silent on a customer who keeps offending.
if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) {
return;
}
final String content = String.format(
"%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.",
client.getDisplayName(), after, WINDOW_DAYS);
this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null,
client.getOffice() == null ? null : client.getOffice().getId());
log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after);
} catch (final RuntimeException e) {
// Consistent with AmlDomainServiceImpl: AML observation must never break the transaction
// it is watching, and a notification is the least important part of that observation.
log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e);
}
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
public interface AmlRecurringAlertReadPlatformService {
/**
* Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}.
*
* @param onlyNeedingReview
* when true, hides customers already reviewed at their current alert count
*/
List<AmlRecurringAlertData> retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview);
/** Alerts in the window for one customer, used to record a review against the count seen. */
int alertCountInWindow(Long clientId, int windowDays);
}
@@ -0,0 +1,134 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Builds the recurring-alert review queue.
*
* The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so
* the rule-severity resolution lives in exactly one place and a customer's High-risk count here can
* never disagree with the Risk Category shown on the alert itself.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService {
private static final int HIGH_RISK = 3;
private final AmlAlertReadPlatformService alertReadPlatformService;
private final AmlRecurringReviewRepository reviewRepository;
private final JdbcTemplate jdbcTemplate;
@Override
public List<AmlRecurringAlertData> retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) {
final LocalDate windowFrom = LocalDate.now().minusDays(windowDays);
final LocalDateTime cutoff = windowFrom.atStartOfDay();
// Grouped in insertion order of the (already newest-first) alert list, so each customer's
// nested alerts stay newest-first without a second sort.
final Map<Long, List<AmlAlertData>> byClient = this.alertReadPlatformService.retrieve(null).stream()
.filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff))
.collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList()));
final List<Long> qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts)
.map(Map.Entry::getKey).toList();
if (qualifying.isEmpty()) {
return List.of();
}
final Map<Long, ClientSummary> summaries = clientSummaries(qualifying);
final List<AmlRecurringAlertData> rows = new ArrayList<>();
for (final Long clientId : qualifying) {
final List<AmlAlertData> alerts = byClient.get(clientId);
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
.orElse(null);
// Reviewed at a lower count means new alerts have landed since — the customer has to
// come back, otherwise one early review would silence them permanently.
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
if (onlyNeedingReview && !needsReview) {
continue;
}
final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null));
rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(),
summary.riskCategory(), alerts.size(),
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null),
alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview,
lastReview == null ? null : lastReview.getAlertCountAtReview(),
lastReview == null ? null : lastReview.getOutcome().name(),
lastReview == null ? null : lastReview.getReviewedBy().getUsername(),
lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(),
windowFrom, alerts));
}
// Worst offenders first; most recent activity breaks ties.
rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed()
.thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder())));
return rows;
}
@Override
public int alertCountInWindow(final Long clientId, final int windowDays) {
final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay();
return (int) this.alertReadPlatformService.retrieve(null).stream()
.filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count();
}
/** Account number and current KYC risk rating, fetched once for the whole queue. */
private Map<Long, ClientSummary> clientSummaries(final List<Long> clientIds) {
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory "
+ "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")";
final Map<Long, ClientSummary> byId = new HashMap<>();
this.jdbcTemplate.query(sql, rs -> {
byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"),
rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory")));
}, clientIds.toArray());
return byId;
}
private record ClientSummary(String accountNo, Integer riskCategory) {}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
public interface AmlRecurringAlertWritePlatformService {
/**
* Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count
* the reviewer was looking at so the customer only resurfaces when that count grows.
*
* @param windowDays
* observation window the reviewer had on screen, so the recorded count matches it
* @return the id of the stored review
*/
Long recordReview(Long clientId, String outcome, String notes, int windowDays);
}
@@ -0,0 +1,91 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService {
private static final String RESOURCE = "amlrecurringreview";
private static final int DEFAULT_WINDOW_DAYS = 90;
private final AmlRecurringReviewRepository reviewRepository;
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
@Override
@Transactional
public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) {
final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes);
final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS;
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
// Counted here rather than taken from the request: a caller-supplied count could be used to
// silence a customer at an inflated number they were never actually reviewed at.
final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window);
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
this.context.authenticatedUser());
return this.reviewRepository.saveAndFlush(review).getId();
}
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
validator.reset().parameter("clientId").value(clientId).notNull();
validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000);
validator.reset().parameter("outcome").value(outcome).notBlank();
AmlRecurringReviewOutcome parsed = null;
if (StringUtils.isNotBlank(outcome)) {
for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) {
if (candidate.name().equalsIgnoreCase(outcome.trim())) {
parsed = candidate;
break;
}
}
if (parsed == null) {
validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported");
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
return parsed;
}
}
@@ -44,6 +44,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
@Override
public List<AmlAlert> evaluate(AmlTransactionContext context) {
@@ -59,6 +60,14 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
raised.add(alertRepository.save(alert));
}
}
// Every transaction type funnels through here, so the recurring-behaviour check is done once
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
// the batch size lets the service tell a first-time offender from a repeat one without
// re-querying what it just wrote.
if (!raised.isEmpty()) {
recurringAlertNotificationService.onAlertsRaised(context.getClient(), raised.size());
}
return raised;
}
@@ -0,0 +1,841 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.clerkwithdrawal.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker withdrawal (redeem shares) request workflow for a Share Account, submitted only
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
* server-side executes the real Fineract "redeemshares" command against the share account, or
* rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
* maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
* redemption is a book-entry, not a cash handover).
*/
@Path("/v1/clerk/share-withdrawal-requests")
@Component
@Tag(name = "Clerk Share Withdrawal Requests", description = "Maker-checker share redemption workflow for clerk and bank manager.")
@RequiredArgsConstructor
public class ClerkShareWithdrawalRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
// simple split('T')[0]/split(' ')[0] date parsing, which expects an ISO-shaped string.
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final String[] LIST_COLUMNS = { "id", "share_account_id", "account_no", "client_id", "client_name",
"requested_shares", "requested_date", "resolution_number", "meeting_date", "description", "status", "clerk_id", "clerk_name",
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submit(final String body) {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
final String requestedDate = fromJsonHelper.extractStringNamed("requestedDate", root);
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final String description = fromJsonHelper.extractStringNamed("description", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
// it, since the CR row's account number is only ever used client-side to double-check against
// the share account's own authoritative linked-savings-account link.
final JsonElement snapshotElement = root.isJsonObject() ? root.getAsJsonObject().get("settlementRowSnapshot") : null;
final String settlementRowSnapshot = snapshotElement != null && !snapshotElement.isJsonNull() ? snapshotElement.toString()
: null;
if (shareAccountId == null) {
throw apiError(400, "Share account id is required");
}
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required");
}
if (requestedShares == null || requestedShares <= 0) {
throw apiError(400, "Number of shares to withdraw must be greater than zero");
}
if (requestedDate == null || requestedDate.isBlank()) {
throw apiError(400, "Requested date is required");
}
if (resolutionNumber == null || resolutionNumber.isBlank()) {
throw apiError(400, "Board Resolution Number is required for a share withdrawal");
}
if (meetingDate == null || meetingDate.isBlank()) {
throw apiError(400, "Date of Meeting is required for a share withdrawal");
}
if (settlementPaymentTypeId == null) {
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
}
validateSettlementPaymentType(settlementPaymentTypeId);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_share_withdrawal_request
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
* Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
* cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
* of what the clerk picked in the UI.
*/
private void validateSettlementPaymentType(final Long paymentTypeId) {
final Map<String, Object> paymentType = jdbcTemplate
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", paymentTypeId).stream().findFirst()
.orElse(null);
if (paymentType == null) {
throw apiError(400, "Settlement Payment Type " + paymentTypeId + " does not exist");
}
final boolean isCash = Boolean.TRUE.equals(paymentType.get("is_cash_payment"));
if (isCash) {
throw apiError(400, "Settlement Payment Type '" + paymentType.get("value")
+ "' is a cash payment type — share redemption settlement to a linked savings account must be a non-cash book-transfer");
}
}
// ── GET /v1/clerk/share-withdrawal-requests ── Manager sees all requests ────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
}
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
@GET
@Path("/my")
@Produces(MediaType.APPLICATION_JSON)
public String myRequests(@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
final AppUser currentUser = context.authenticatedUser();
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
if (status != null && !status.isBlank()) {
where.append(" AND status = ?");
params.add(status);
}
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
}
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
final int offset, final int limit) {
final StringBuilder where = new StringBuilder(baseWhere);
final List<Object> params = new ArrayList<>(baseParams);
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
params.add(Date.valueOf(fromDate));
}
if (toDate != null && !toDate.isBlank()) {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
}
final long total = Optional
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
params.toArray()))
.orElse(0L);
final List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
final JsonObject result = new JsonObject();
result.addProperty("totalFilteredRecords", total);
result.add("pageItems", gson.toJsonTree(rows));
return gson.toJson(result);
}
// ── GET /v1/clerk/share-withdrawal-requests/{id} ── Full detail ─────────────
/**
* Unlike list()/myRequests(), this single-record detail also carries the client's compliance
* summary (KYC status, Risk Category, open AML alert count) — so the review screen can show it
* permanently on the page, not only inside the on-demand /validate popup.
*/
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) {
final Map<?, ?> record = loadRecord(id);
final Map<String, Object> enriched = new java.util.LinkedHashMap<>();
for (final Map.Entry<?, ?> entry : record.entrySet()) {
enriched.put(String.valueOf(entry.getKey()), entry.getValue());
}
final Object clientIdObj = record.get("client_id");
final Long clientId = clientIdObj instanceof Number ? ((Number) clientIdObj).longValue() : null;
enriched.putAll(loadClientComplianceSummary(clientId));
return gson.toJson(enriched);
}
private Map<String, Object> loadClientComplianceSummary(final Long clientId) {
final Map<String, Object> summary = new java.util.LinkedHashMap<>();
if (clientId == null) {
return summary;
}
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
WHERE client_id = ? ORDER BY id DESC LIMIT 1
""", clientId).stream().findFirst().orElse(null);
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null ? ((Number) kycRow.get("riskCategory")).intValue()
: null;
final Long openAlerts = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
summary.put("client_kyc_status_label", kycStatus != null ? kycStatusLabel(kycStatus) : "No KYC record");
summary.put("client_kyc_verified", kycStatus != null && kycStatus == 300);
summary.put("client_risk_category_label", riskCategory != null ? riskCategoryLabel(riskCategory) : "Not classified");
summary.put("client_risk_high", riskCategory != null && riskCategory == 3);
summary.put("client_open_aml_alerts", openAlerts != null ? openAlerts : 0L);
return summary;
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ──
/**
* Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so
* the checker can catch things that changed since submission — a share/savings account that got
* closed, a payment type that got deactivated, a client whose KYC lapsed or who now has an open
* AML alert, an active loan secured by these very shares ({@code m_share_account.linked_loan_id}
* — this bank supports loan-against-shares), that loan's tenure not yet being complete, this
* client currently guaranteeing someone else's active loan, or (the exact bug class this session
* ran into) a savings product's GL Fund Source mapping for this payment type going missing, which
* would silently post the settlement leg to the default Cash at Vault account instead of Share
* Sundry.
*/
@POST
@Path("/{id}/validate")
@Produces(MediaType.APPLICATION_JSON)
public String validate(@PathParam("id") final Long id) {
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
final List<Map<String, Object>> checks = new ArrayList<>();
final String resolutionNumber = (String) record.get("resolution_number");
checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(),
resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber));
final Object meetingDate = record.get("meeting_date");
checks.add(checkResult("Date of Meeting provided", meetingDate != null,
meetingDate == null ? "Missing" : meetingDate.toString().substring(0, Math.min(10, meetingDate.toString().length()))));
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null);
final long heldShares = shareAccountRow != null && shareAccountRow.get("totalApprovedShares") != null
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
: 0L;
checks.add(checkResult("Shares requested within current shares held", shareAccountRow != null && requestedShares <= heldShares,
requestedShares + " of " + heldShares + " shares currently held"));
final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null
? ((Number) shareAccountRow.get("statusEnum")).intValue()
: -1;
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
: null;
final Long clientId = shareAccountRow != null && shareAccountRow.get("clientId") != null
? ((Number) shareAccountRow.get("clientId")).longValue()
: null;
Integer savingsStatus = null;
Long savingsProductId = null;
if (savingsAccountId != null) {
final Map<String, Object> savingsRow = jdbcTemplate
.queryForList("SELECT status_enum AS statusEnum, product_id AS productId FROM m_savings_account WHERE id = ?",
savingsAccountId)
.stream().findFirst().orElse(null);
savingsStatus = savingsRow != null && savingsRow.get("statusEnum") != null ? ((Number) savingsRow.get("statusEnum")).intValue()
: null;
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
: null;
}
checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
savingsAccountId == null ? "No linked savings account configured"
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
// Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id)
// when shares secure a loan against shares, so a withdrawal must not weaken that security while the
// loan is still outstanding, and separately, releasing shares to a client currently on the hook as a
// guarantor for someone else's active loan deserves the same scrutiny.
final Long linkedLoanId = shareAccountRow != null && shareAccountRow.get("linkedLoanId") != null
? ((Number) shareAccountRow.get("linkedLoanId")).longValue()
: null;
Map<String, Object> linkedLoanRow = null;
if (linkedLoanId != null) {
linkedLoanRow = jdbcTemplate.queryForList("""
SELECT account_no AS accountNo, loan_status_id AS statusId, expected_maturedon_date AS expectedMaturedOnDate
FROM m_loan WHERE id = ?
""", linkedLoanId).stream().findFirst().orElse(null);
}
final Integer linkedLoanStatus = linkedLoanRow != null && linkedLoanRow.get("statusId") != null
? ((Number) linkedLoanRow.get("statusId")).intValue()
: null;
final boolean linkedLoanOutstanding = linkedLoanStatus != null && isLoanStatusOutstanding(linkedLoanStatus);
final String loanCheckDetail;
if (linkedLoanId == null) {
loanCheckDetail = "No loan linked to this share account";
} else if (linkedLoanRow == null) {
loanCheckDetail = "Loan #" + linkedLoanId + " — record not found";
} else {
loanCheckDetail = "Loan " + linkedLoanRow.get("accountNo") + " — " + loanStatusLabel(linkedLoanStatus);
}
checks.add(checkResult("Loan Check — no active loan secured by these shares", linkedLoanId == null || !linkedLoanOutstanding,
loanCheckDetail));
boolean tenureOk = true;
String tenureDetail = "No linked loan";
if (linkedLoanId != null && linkedLoanOutstanding && linkedLoanRow != null) {
final Object maturedOnObj = linkedLoanRow.get("expectedMaturedOnDate");
if (maturedOnObj instanceof java.sql.Date maturedOnDate) {
final boolean matured = !maturedOnDate.toLocalDate()
.isAfter(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
tenureOk = matured;
tenureDetail = "Linked loan term ends " + maturedOnDate.toLocalDate() + (matured ? " — completed" : " — not yet completed");
} else {
tenureOk = false;
tenureDetail = "Linked loan is active with no expected maturity date on record";
}
}
checks.add(checkResult("Tenure Check — linked loan term completed", tenureOk, tenureDetail));
if (clientId != null) {
final Long guarantorActiveLoans = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_guarantor g JOIN m_loan l ON l.id = g.loan_id
WHERE g.entity_id = ? AND g.type_enum = 1 AND g.is_active = true
AND l.loan_status_id IN (300, 303, 304, 700)
""", Long.class, clientId);
checks.add(checkResult("Guarantor Check — not guaranteeing any active loan",
guarantorActiveLoans == null || guarantorActiveLoans == 0,
guarantorActiveLoans != null && guarantorActiveLoans > 0 ? "Guarantor on " + guarantorActiveLoans + " active loan(s)"
: "None"));
}
final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id");
final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number
? ((Number) settlementPaymentTypeIdObj).longValue()
: null;
boolean paymentTypeNonCash = false;
String paymentTypeDetail = "Missing";
if (settlementPaymentTypeId != null) {
final Map<String, Object> pt = jdbcTemplate
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream()
.findFirst().orElse(null);
if (pt != null) {
paymentTypeNonCash = !Boolean.TRUE.equals(pt.get("is_cash_payment"));
paymentTypeDetail = pt.get("value") + (paymentTypeNonCash ? "" : " (cash — not allowed)");
}
}
checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail));
boolean glMappingOk = false;
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
if (savingsProductId != null && settlementPaymentTypeId != null) {
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
SELECT gl.name AS glName FROM acc_product_mapping apm
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
glMappingOk = mapping != null;
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
}
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
if (clientId != null) {
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
WHERE client_id = ? ORDER BY id DESC LIMIT 1
""", clientId).stream().findFirst().orElse(null);
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
checks.add(checkResult("Client KYC is Verified", kycStatus != null && kycStatus == 300,
kycStatus == null ? "No KYC record found" : kycStatusLabel(kycStatus)));
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null
? ((Number) kycRow.get("riskCategory")).intValue()
: null;
checks.add(checkResult("Client Risk Category is not High", riskCategory == null || riskCategory != 3,
riskCategory == null ? "Not classified" : riskCategoryLabel(riskCategory)));
final Long openAlerts = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
checks.add(checkResult("No open AML alerts for this client", openAlerts == null || openAlerts == 0,
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
final Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("label", label);
result.put("pass", pass);
result.put("detail", detail);
return result;
}
private boolean isLoanStatusOutstanding(final int loanStatusId) {
return loanStatusId == 300 || loanStatusId == 303 || loanStatusId == 304 || loanStatusId == 700;
}
private String loanStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 303 -> "Transfer in Progress";
case 304 -> "Transfer on Hold";
case 400 -> "Withdrawn by Client";
case 500 -> "Rejected";
case 600 -> "Closed (Obligations Met)";
case 601 -> "Closed (Written Off)";
case 602 -> "Closed (Rescheduled)";
case 700 -> "Overpaid";
default -> "Unknown (" + code + ")";
};
}
private String shareStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 500 -> "Rejected";
case 600 -> "Closed";
default -> "Unknown (" + code + ")";
};
}
private String savingsStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 303 -> "Transfer in Progress";
case 304 -> "Transfer on Hold";
case 400 -> "Withdrawn by Applicant";
case 500 -> "Rejected";
case 600 -> "Closed";
case 700 -> "Pre-mature Closure";
case 800 -> "Matured";
case 900 -> "Terminated";
default -> "Unknown (" + code + ")";
};
}
private String kycStatusLabel(final int code) {
return switch (code) {
case 100 -> "Pending";
case 200 -> "In Progress";
case 300 -> "Verified";
case 400 -> "Rejected";
case 500 -> "Expired";
default -> "Unknown (" + code + ")";
};
}
private String riskCategoryLabel(final int code) {
return switch (code) {
case 1 -> "Low";
case 2 -> "Medium";
case 3 -> "High";
default -> "Unknown (" + code + ")";
};
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/approve ── Manager approves ──
@POST
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403,
"Maker-checker: you cannot approve a share withdrawal request you submitted yourself. Ask another manager to review it.");
}
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Object requestedDateObj = record.get("requested_date");
final Object meetingDateObj = record.get("meeting_date");
final String resolutionNumber = (String) record.get("resolution_number");
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("requestedDate", formatForFineract(requestedDateObj.toString()));
payload.addProperty("requestedShares", requestedShares);
payload.addProperty("resolutionNumber", resolutionNumber);
payload.addProperty("meetingDate", formatForFineract(meetingDateObj.toString()));
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// redeemShares() puts the newly-created share transaction's id under "requestedShares" in
// the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id
// instead. Reading getResourceId() here would silently look up the wrong row downstream.
final Object shareTxnIdRaw = result.getChanges() != null ? result.getChanges().get("requestedShares") : null;
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
// Settlement: within this SAME approval transaction, immediately credit the redemption value
// to the member's linked savings account. Two Fineract journal entries get posted — native
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
? ((Number) storedSettlementPaymentTypeId).longValue()
: null;
final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
resolutionNumber, settlementPaymentTypeId);
final String settlementNote = settlement != null
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
: "No linked savings account on this share account — settlement must be completed manually.";
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
settlementNote);
final int approvedRows = jdbcTemplate.update("""
UPDATE m_clerk_share_withdrawal_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
share_transaction_id = ?, activity_log = ?, actioned_at = ?,
settlement_amount = ?, settlement_savings_transaction_id = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
settlement != null ? settlement.savingsTransactionId() : null, id);
if (approvedRows == 0) {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
}
// If this redemption brought the account's holding to zero, close the share account right
// here in the SAME approval action — no separate "Share Closure Request" maker-checker step.
// Safe to do inline (unlike a raw manual "Close" from the account screen) because the
// redemption amount itself already went through this same Maker-Checker review; there are no
// shares left for the native close command to force-redeem without oversight.
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
res.addProperty("status", "APPROVED");
if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId);
}
if (settlement != null) {
res.addProperty("settlementAmount", settlement.amount());
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
} else {
res.addProperty("settlementWarning", settlementNote);
}
if (accountClosed) {
res.addProperty("accountClosed", true);
}
return gson.toJson(res);
}
/**
* Closes the share account when this redemption has just brought its holding to zero — the
* account closes immediately on approval, in the same step as the withdrawal itself, rather than
* needing a separate closure request to be submitted and approved afterwards.
*/
private boolean closeShareAccountIfSharesZero(final Long shareAccountId, final String requestedDateStr) {
final Long remainingShares = jdbcTemplate.queryForObject("SELECT total_approved_shares FROM m_share_account WHERE id = ?",
Long.class, shareAccountId);
if (remainingShares == null || remainingShares != 0) {
return false;
}
final JsonObject closePayload = new JsonObject();
closePayload.addProperty("locale", "en");
closePayload.addProperty("dateFormat", "dd MMMM yyyy");
closePayload.addProperty("closedDate", formatForFineract(requestedDateStr));
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
.withJson(closePayload.toString()).build();
commandsSourceWritePlatformService.logCommandSource(closeCommand);
return true;
}
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
/**
* Credits the redemption value to the share account's linked savings account, if one exists.
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
* the unit price ourselves — the transaction was already priced by Fineract's own
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
* command so the transaction is fully audited and posts its own GL entry the normal way — the
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
* on that bridge account. Falls back to the legacy hardcoded
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
* column existed.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final String resolutionNumber, final Long settlementPaymentTypeId) {
if (shareTransactionId == null) {
return null;
}
final Map<String, Object> row = jdbcTemplate.queryForList("""
SELECT sa.savings_account_id AS savingsAccountId, sat.amount AS settlementAmount
FROM m_share_account_transactions sat JOIN m_share_account sa ON sa.id = sat.account_id
WHERE sat.id = ?
""", shareTransactionId).stream().findFirst().orElse(null);
if (row == null) {
return null;
}
final Long savingsAccountId = row.get("savingsAccountId") == null ? null : ((Number) row.get("savingsAccountId")).longValue();
if (savingsAccountId == null) {
return null;
}
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId
: jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class,
SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null);
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", formatForFineract(requestedDateStr));
payload.addProperty("transactionAmount", settlementAmount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", paymentTypeId);
}
payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber);
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return new Settlement(settlementAmount, result.getResourceId());
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@POST
@Path("/{id}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
}
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (reason == null || reason.isBlank()) {
throw apiError(400, "Rejection reason is required");
}
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason);
final int rejectedRows = jdbcTemplate.update("""
UPDATE m_clerk_share_withdrawal_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
rejection_reason = ?, activity_log = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), reason, updatedLog,
Timestamp.from(Instant.now()), id);
if (rejectedRows == 0) {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
}
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject share withdrawal requests.");
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new NotFoundException("Share withdrawal request not found: " + id);
}
return rows.get(0);
}
private String formatForFineract(final String isoOrTimestamp) {
try {
final String isoDatePart = isoOrTimestamp.length() >= 10 ? isoOrTimestamp.substring(0, 10) : isoOrTimestamp;
final LocalDate ld = LocalDate.parse(isoDatePart, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
JsonArray log = new JsonArray();
if (existingJson != null && !existingJson.isBlank()) {
try {
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
} catch (Exception e) {
log = new JsonArray();
}
}
final JsonObject entry = new JsonObject();
entry.addProperty("timestamp", Instant.now().toString());
entry.addProperty("action", action);
entry.addProperty("user", username);
if (note != null && !note.isBlank()) {
entry.addProperty("note", note);
}
log.add(entry);
return gson.toJson(log);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,111 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.service.KycContactLogService;
import org.springframework.stereotype.Component;
/**
* Customer-outreach log for KYC exceptions.
*
* <pre>
* POST /v1/kyc/exceptions/{clientId}/contact-log body: {"channel":"SMS","notes":"...","officerName":"..."}
* GET /v1/kyc/exceptions/{clientId}/contact-log
* </pre>
*
* <p>POST both records the attempt and — for SMS / Email / WhatsApp — sends the notice to the
* customer. The response carries {@code deliveryStatus} and {@code deliveryMessage} so the caller
* can distinguish a delivered notice from a merely-recorded one; a send failure is reported in the
* body with HTTP 200, not as an error, because the log row was still written.
*
* <p>No PUT or DELETE: the log is append-only evidence.
*
* <p>Authorisation reuses the client UPDATE permission — contacting a customer about their KYC is a
* maintenance action on that customer's record, and every KYC officer who can resolve an exception
* already holds it.
*/
@Path("/v1/kyc/exceptions/{clientId}/contact-log")
@Component
@Tag(name = "Client KYC Exceptions", description = "Record and send customer outreach for KYC exceptions.")
@RequiredArgsConstructor
public class ClientKycContactLogApiResource {
private final PlatformSecurityContext context;
private final KycContactLogService kycContactLogService;
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Log a customer contact attempt for a KYC exception",
description = "Records the attempt. For channel SMS, EMAIL or WHATSAPP the notice is also transmitted to the "
+ "customer using the destination held on their client record. Returns the persisted log including "
+ "its delivery outcome.")
public String logContact(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final JsonObject payload = parse(apiRequestBodyAsJson);
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve the customer-contact history for a KYC exception",
description = "All logged outreach for this customer, oldest first.")
public String retrieveContactLog(@PathParam("clientId") final Long clientId) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<KycContactLogData> logs = this.kycContactLogService.retrieveForClient(clientId);
return this.toApiJsonSerializer.serialize(logs);
}
private JsonObject parse(final String json) {
if (json == null || json.isBlank()) {
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(json);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private String stringOrNull(final JsonObject payload, final String member) {
final JsonElement element = payload.get(member);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
}
@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientKycData;
import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.ws.rs.QueryParam;
@Path("/v1/kyc/exceptions")
@Component
@Tag(name = "Client KYC Exceptions", description = "Retrieve active client KYC exceptions.")
@RequiredArgsConstructor
public class ClientKycExceptionsApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientKycExceptionsApiResource.class);
private final PlatformSecurityContext context;
private final ClientKycReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<ClientKycData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve client KYC exceptions", description = "Returns the list of all clients with active or resolved KYC exceptions.")
public String retrieveActiveExceptions(@QueryParam("resolved") final Boolean resolved) {
this.context.authenticatedUser();
final List<ClientKycData> exceptions;
if (Boolean.TRUE.equals(resolved)) {
exceptions = this.readPlatformService.retrieveResolvedKycExceptions();
LOG.info("retrieveActiveExceptions: fetched {} resolved exception(s) from database", exceptions.size());
} else {
exceptions = this.readPlatformService.retrieveActiveKycExceptions();
LOG.info("retrieveActiveExceptions: fetched {} active exception(s) from database", exceptions.size());
}
for (ClientKycData k : exceptions) {
LOG.info(" -> Exception: id={}, clientId={}, clientName='{}', isException={}, exceptionResolved={}",
k.getId(), k.getClientId(), k.getClientDisplayName(), k.getIsException(), k.getExceptionResolved());
}
return this.toApiJsonSerializer.serialize(exceptions);
}
}
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/kyc/risk-rules")
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
private final PlatformSecurityContext context;
private final KycRiskRulesReadPlatformService readPlatformService;
private final KycRiskRulesWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
@GET
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
return this.toApiJsonSerializer.serialize(rules);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Add a KYC/AML risk rule",
description = "Appends a rule to the catalog. Supplying any transaction band or observation window also "
+ "creates the matching live-transaction rule, linked back to the new catalog entry. Returns the "
+ "persisted rule in the same shape as the catalog GET.")
public String createRiskRule(final KycRiskRuleRequest request) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
return this.toApiJsonSerializer.serialize(rule);
}
}
@@ -41,6 +41,9 @@ public class ClientKycCommand {
private final Boolean verifyPan;
private final Boolean verifyAadhaar;
private final Boolean verifyGstin;
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
// ── Aadhaar OTP flow ──────────────────────────────────────────────────────
/** Full Aadhaar – used ONLY for OTP generation; never stored. */
@@ -54,7 +57,8 @@ public class ClientKycCommand {
final String aadhaarNumberMasked, final String gstinNumber, final String remarks,
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber,
final String aadhaarOtp) {
final String aadhaarOtp, final Boolean isException, final String exceptionReason,
final Boolean exceptionResolved) {
this.kycType = kycType;
this.riskCategory = riskCategory;
this.panNumber = panNumber;
@@ -68,6 +72,9 @@ public class ClientKycCommand {
this.aadhaarNumber = aadhaarNumber;
this.aadhaarRefNumber = aadhaarRefNumber;
this.aadhaarOtp = aadhaarOtp;
this.isException = isException;
this.exceptionReason = exceptionReason;
this.exceptionResolved = exceptionResolved;
}
public void validateForInitiate() {
@@ -111,8 +118,9 @@ public class ClientKycCommand {
}
// At least one ID document must be provided — except for minors, who prove identity via
// the Birth Certificate / School ID document-type picker instead.
if (!skipIdentityDocumentCheck) {
// the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
final boolean isKycException = isException != null && isException;
if (!skipIdentityDocumentCheck && !isKycException) {
final boolean hasPan = panNumber != null && !panNumber.isBlank();
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
@@ -250,4 +258,16 @@ public class ClientKycCommand {
public String getAadhaarRefNumber() { return aadhaarRefNumber; }
/** OTP entered by the client. */
public String getAadhaarOtp() { return aadhaarOtp; }
public Boolean getIsException() {
return isException;
}
public String getExceptionReason() {
return exceptionReason;
}
public Boolean getExceptionResolved() {
return exceptionResolved;
}
}
@@ -39,6 +39,9 @@ public final class ClientKycApiConstants {
public static final String verifyPanParam = "verifyPan";
public static final String verifyAadhaarParam = "verifyAadhaar";
public static final String verifyGstinParam = "verifyGstin";
public static final String panVerifiedParam = "panVerified";
public static final String aadhaarVerifiedParam = "aadhaarVerified";
public static final String gstinVerifiedParam = "gstinVerified";
// ── Aadhaar OTP flow parameters ───────────────────────────────────────────
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */
@@ -50,6 +53,22 @@ public final class ClientKycApiConstants {
// ── Extended KYC fields ──────────────────────────────────────────────────
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus";
// ── PAN declaration (accepted when the PAN check cannot pass) ─────────────
public static final String panDeclarationTakenParam = "panDeclarationTaken";
public static final String panDeclarationMismatchFieldsParam = "panDeclarationMismatchFields";
public static final String panDeclarationNameOnPanParam = "panDeclarationNameOnPan";
public static final String panDeclarationDobOnPanParam = "panDeclarationDobOnPan";
public static final String panDeclarationRemarksParam = "panDeclarationRemarks";
public static final String panDeclarationTakenOnParam = "panDeclarationTakenOn";
// ── Aadhaar enrolment receipt (accepted when Aadhaar is not yet issued) ───
public static final String aadhaarReceiptTakenParam = "aadhaarReceiptTaken";
public static final String aadhaarEnrolmentIdParam = "aadhaarEnrolmentId";
public static final String aadhaarEnrolmentDateParam = "aadhaarEnrolmentDate";
public static final String aadhaarReceiptRemarksParam = "aadhaarReceiptRemarks";
public static final String aadhaarReceiptTakenOnParam = "aadhaarReceiptTakenOn";
public static final String enterprisePanParam = "enterprisePan";
public static final String cinNumberParam = "cinNumber";
public static final String dinNumberParam = "dinNumber";
@@ -69,11 +88,19 @@ public final class ClientKycApiConstants {
public static final String accountStatusParam = "accountStatus";
public static final String freezeReasonParam = "freezeReason";
public static final String freezeAuthorityParam = "freezeAuthority";
public static final String freezeDateParam = "freezeDate";
public static final String freezeAmountParam = "freezeAmount";
public static final String unfreezeDateParam = "unfreezeDate";
public static final String noDebitDateParam = "noDebitDate";
public static final String form60TypeParam = "form60Type";
public static final String form60DateParam = "form60Date";
public static final String form15ApplicableParam = "form15Applicable";
public static final String form15TypeParam = "form15Type";
public static final String form15FYParam = "form15FY";
public static final String isExceptionParam = "isException";
public static final String exceptionReasonParam = "exceptionReason";
public static final String exceptionResolvedParam = "exceptionResolved";
public static final String exceptionResolvedOnParam = "exceptionResolvedOn";
// ── Supported parameters for initiate/update ──────────────────────────────
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -83,16 +110,30 @@ public final class ClientKycApiConstants {
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
riskCategoryParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.data;
import java.time.LocalDate;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@@ -52,11 +53,29 @@ public final class ClientKycData {
private final String panCategory;
private final String panStatus;
// ── PAN declaration ───────────────────────────────────────────────────────
/** Signed customer declaration accepted in place of a passing PAN check. */
private final Boolean panDeclarationTaken;
/** "NAME", "DOB", "NAME,DOB", or "NONE" when PAN could not be checked at all. */
private final String panDeclarationMismatchFields;
private final String panDeclarationNameOnPan;
private final LocalDate panDeclarationDobOnPan;
private final String panDeclarationRemarks;
private final LocalDate panDeclarationTakenOn;
// ── Aadhaar ───────────────────────────────────────────────────────────────
private final String aadhaarNumberMasked;
private final Boolean aadhaarVerified;
private final LocalDate aadhaarVerifiedOn;
// ── Aadhaar enrolment receipt ─────────────────────────────────────────────
/** Enrolment slip accepted for a customer who has applied for Aadhaar but holds none yet. */
private final Boolean aadhaarReceiptTaken;
private final String aadhaarEnrolmentId;
private final LocalDate aadhaarEnrolmentDate;
private final String aadhaarReceiptRemarks;
private final LocalDate aadhaarReceiptTakenOn;
// ── GSTIN ─────────────────────────────────────────────────────────────────
private final String gstinNumber;
private final Boolean gstinVerified;
@@ -65,6 +84,8 @@ public final class ClientKycData {
// ── Enterprise / Non-Individual ──────────────────────────────────────────
private final String enterprisePan;
private final String cinNumber;
private final Boolean cinVerified;
private final LocalDate cinVerifiedOn;
private final LocalDate incorporationDate;
// ── KYC Document ─────────────────────────────────────────────────────────
@@ -84,6 +105,10 @@ public final class ClientKycData {
private final String accountStatus;
private final String freezeReason;
private final String freezeAuthority;
private final LocalDate freezeDate;
private final BigDecimal freezeAmount;
private final LocalDate unfreezeDate;
private final LocalDate noDebitDate;
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
private final String form60Type;
@@ -97,10 +122,29 @@ public final class ClientKycData {
private final LocalDate ckycUploadedOn;
private final String ckycBatchId;
// ── Exception management ──────────────────────────────────────────────────
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
private final LocalDate exceptionResolvedOn;
// ── Lifecycle ─────────────────────────────────────────────────────────────
private final LocalDate initiatedOn;
private final LocalDate verifiedOn;
private final LocalDate rejectedOn;
private final LocalDate expiryDate;
private final String remarks;
private final String clientAccountNo;
private final String clientOfficeName;
// ── Customer contact (for KYC outreach) ──────────────────────────────────
// Masked only. The exceptions screen shows these so the officer knows who they are reaching,
// but the full values never leave the server — the send path resolves them from m_client
// itself, so a tampered browser cannot redirect a KYC notice to another address.
private final String clientMobileMasked;
private final String clientEmailMasked;
/** Whether a destination exists at all, so the UI can grey out a channel that cannot be used. */
private final Boolean hasMobile;
private final Boolean hasEmail;
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.data;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Data;
/**
@@ -51,6 +52,45 @@ public class ClientKycRequest {
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
private String panAadhaarSeedingStatus;
@Schema(description = "True when a signed customer declaration has been accepted in place of a passing "
+ "PAN check. Suppresses the automatic 'PAN Card Verification Failed' KYC exception.", example = "true")
private Boolean panDeclarationTaken;
@Schema(description = "Which client details differ from the PAN card: NAME, DOB, NAME,DOB, or NONE when "
+ "PAN could not be checked at all", example = "NAME")
private String panDeclarationMismatchFields;
@Schema(description = "Name exactly as printed on the physical PAN card", example = "RAJESH KUMAR SHARMA")
private String panDeclarationNameOnPan;
@Schema(description = "Date of birth exactly as printed on the physical PAN card", example = "12 May 1988")
private String panDeclarationDobOnPan;
@Schema(description = "Officer's note on why the mismatch exists and what proof was sighted",
example = "Client's PAN carries expanded middle name; passport sighted.")
private String panDeclarationRemarks;
@Schema(description = "Date the declaration was accepted", example = "07 August 2026")
private String panDeclarationTakenOn;
@Schema(description = "True when the customer's Aadhaar enrolment slip has been sighted and recorded, for a "
+ "customer who has applied for Aadhaar but does not hold one yet. Suppresses the automatic "
+ "'Aadhaar Verification Failed' KYC exception.", example = "true")
private Boolean aadhaarReceiptTaken;
@Schema(description = "28-digit Enrolment ID (EID) printed on the UIDAI enrolment slip",
example = "1234/12345/12345 12/05/2026 11:30:15")
private String aadhaarEnrolmentId;
@Schema(description = "Enrolment date printed on the slip", example = "12 May 2026")
private String aadhaarEnrolmentDate;
@Schema(description = "Officer's note on what was sighted", example = "Original UIDAI enrolment slip sighted.")
private String aadhaarReceiptRemarks;
@Schema(description = "Date the receipt was accepted", example = "07 August 2026")
private String aadhaarReceiptTakenOn;
@Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
private String enterprisePan;
@@ -96,6 +136,18 @@ public class ClientKycRequest {
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
private String freezeAuthority;
@Schema(description = "Date of account freeze (null if not frozen)")
private String freezeDate;
@Schema(description = "Amount frozen (null if not frozen)")
private BigDecimal freezeAmount;
@Schema(description = "Date the account was unfrozen (null unless accountStatus is UNFREEZE)")
private String unfreezeDate;
@Schema(description = "Date the no-debit restriction takes effect (null unless accountStatus is NO_DEBIT)")
private String noDebitDate;
@Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
private String form60Type;
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
/**
* One KYC customer-contact attempt as returned to the UI.
*
* <p>{@code deliveryStatus} and {@code deliveryMessage} exist so the officer can tell a delivered
* notice from a failed one at a glance — the dialog shows the failure verbatim rather than a
* generic success toast.
*/
@Getter
@Builder
public final class KycContactLogData {
private final Long id;
private final Long clientId;
private final Long kycId;
/** Enum name, e.g. {@code SMS}. */
private final String channel;
/** Human label, e.g. {@code SMS}/{@code In-Branch Visit} — what the grid renders. */
private final String channelLabel;
private final String notes;
private final String officerName;
private final String exceptionReason;
/** NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED */
private final String deliveryStatus;
/** True only for a channel that was supposed to transmit and did. */
private final Boolean delivered;
/** True when this channel transmits at all (SMS/Email/WhatsApp) vs. is recorded offline. */
private final Boolean platformSent;
/** Officer-facing sentence describing the outcome — success or the reason it failed. */
private final String deliveryMessage;
private final String destinationMasked;
private final String provider;
private final String providerReference;
private final String messageBody;
private final LocalDateTime contactedAt;
}
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/kyc/risk-rules}.
*
* Mirrors the columns the AML Rules Master screen displays. The {@code txn*} fields describe the
* live-transaction counterpart of the rule; supplying any of them creates a companion row in
* {@code m_aml_transaction_rule} linked back to the new {@code m_client_risk_rule} row. Leave them
* all blank for a pure KYC-time profile rule (income / turnover / occupation driven).
*/
@Getter
@Setter
@NoArgsConstructor
public class KycRiskRuleRequest {
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
private String ruleName;
@Schema(description = "Reason / criteria text explaining why the rule exists",
requiredMode = Schema.RequiredMode.REQUIRED)
private String reason;
@Schema(description = "Risk category the rule assigns: 1 Low, 2 Medium, 3 High",
requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
private Integer riskCategory;
@Schema(description = "Position in the catalog. Omit to append after the last existing rule.")
private Integer orderPosition;
@Schema(description = "Legal form the rule is restricted to (1 Person, 2 Entity). Null applies to all.")
private Integer legalFormId;
@Schema(description = "Occupation category code value the rule is restricted to")
private Long occupationCategoryCvId;
@Schema(description = "Occupation code value the rule is restricted to")
private Long occupationCvId;
private BigDecimal minMonthlyIncome;
private BigDecimal maxMonthlyIncome;
private BigDecimal minAnnualIncome;
private BigDecimal maxAnnualIncome;
private BigDecimal minAnnualTurnover;
private BigDecimal maxAnnualTurnover;
@Schema(description = "AML check status the rule records", example = "FLAGGED")
private String amlCheckStatus;
@Schema(description = "Banned entity match flag", example = "NO")
private String bannedEntityMatch;
@Schema(description = "Willful defaulter flag", example = "NO")
private String willfulDefaulter;
@Schema(description = "ECGC/SAL flag", example = "NO")
private String ecgcSalFlag;
@Schema(description = "Dormancy flag", example = "ACTIVE")
private String dormantFlag;
@Schema(description = "Lower bound of the transaction band the rule covers (display only)")
private BigDecimal txnMinLimitAmount;
@Schema(description = "Upper bound of the transaction band. Null means the rule is open-ended.")
private BigDecimal txnMaxLimitAmount;
@Schema(description = "Amount at which the rule fires on a live transaction. Defaults to the "
+ "lower bound of the band when omitted.")
private BigDecimal txnThresholdAmount;
@Schema(description = "Observation window in days. Omit or 0 for per-transaction.", example = "30")
private Integer txnWindowDays;
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
private String txnRuleType;
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
private String txnAppliesTo;
/**
* True when the officer has described a live-transaction rule rather than a pure KYC profile
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
*/
public boolean hasTransactionRule() {
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
}
}
@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Routes an outbound customer notice to the {@link CustomerNotificationSender} registered for its
* channel.
*
* <p>Senders are injected as a list and indexed by channel at startup, so a new gateway becomes
* available by declaring a bean — nothing here changes.
*/
@Component
public class CustomerNotificationDispatcher {
private static final Logger LOG = LoggerFactory.getLogger(CustomerNotificationDispatcher.class);
private final Map<KycContactChannel, CustomerNotificationSender> sendersByChannel = new EnumMap<>(KycContactChannel.class);
public CustomerNotificationDispatcher(final List<CustomerNotificationSender> senders) {
for (final CustomerNotificationSender sender : senders) {
final CustomerNotificationSender existing = this.sendersByChannel.put(sender.getChannel(), sender);
if (existing != null) {
LOG.warn("Two senders registered for channel {} — {} replaced {}", sender.getChannel(),
sender.getClass().getSimpleName(), existing.getClass().getSimpleName());
}
}
LOG.info("Customer notification channels available: {}", this.sendersByChannel.keySet());
}
/**
* Send on the given channel.
*
* @return the outcome; {@link CustomerNotificationResult#notConfigured} for an offline channel
* or one with no registered sender. Never throws for a delivery failure.
*/
public CustomerNotificationResult dispatch(final KycContactChannel channel, final Client client, final String subject,
final String plainBody, final String htmlBody) {
if (!channel.isPlatformSent()) {
// Caller shouldn't reach here for PHONE_CALL / visits, but guard rather than send.
return CustomerNotificationResult.notConfigured(null, "Channel " + channel + " is recorded offline, not transmitted");
}
final CustomerNotificationSender sender = this.sendersByChannel.get(channel);
if (sender == null) {
LOG.warn("No sender registered for channel {} — notice to client {} not transmitted.", channel, client.getId());
return CustomerNotificationResult.notConfigured(null, "No gateway is registered for " + channel);
}
return sender.send(client, subject, plainBody, htmlBody);
}
}
@@ -0,0 +1,51 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
/**
* What a {@link CustomerNotificationSender} did. Senders never throw for a delivery failure — they
* return a {@code FAILED} result — so that a gateway outage still produces a persisted contact-log
* row rather than losing the officer's notes to a rolled-back transaction.
*/
public record CustomerNotificationResult(KycContactDeliveryStatus status, String destinationMasked, String provider,
String providerReference, String failureReason) {
public static CustomerNotificationResult sent(final String destinationMasked, final String provider, final String reference) {
return new CustomerNotificationResult(KycContactDeliveryStatus.SENT, destinationMasked, provider, reference, null);
}
public static CustomerNotificationResult failed(final String destinationMasked, final String provider, final String reason) {
return new CustomerNotificationResult(KycContactDeliveryStatus.FAILED, destinationMasked, provider, null, reason);
}
public static CustomerNotificationResult noDestination(final String provider) {
return new CustomerNotificationResult(KycContactDeliveryStatus.SKIPPED_NO_DESTINATION, null, provider, null,
"Customer has no destination on file for this channel");
}
public static CustomerNotificationResult notConfigured(final String provider, final String reason) {
return new CustomerNotificationResult(KycContactDeliveryStatus.PROVIDER_NOT_CONFIGURED, null, provider, null, reason);
}
public boolean isDelivered() {
return this.status.isDelivered();
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
/**
* Transport for one outbound customer channel.
*
* <p>Implementations are discovered by Spring and matched to a channel via {@link #getChannel()},
* so adding a gateway means adding a bean — no dispatcher edit. Each implementation is responsible
* for resolving the destination off the {@link Client} itself; callers never pass a destination in,
* which keeps a browser from being able to redirect a KYC notice to an arbitrary address.
*
* <p>Contract: never throw for a delivery problem. Return
* {@link CustomerNotificationResult#failed} instead, so the officer's contact log still persists.
*/
public interface CustomerNotificationSender {
KycContactChannel getChannel();
/**
* @param client the customer to reach; the destination is read off this record
* @param subject short title — used by channels that have one (email), ignored by those that don't
* @param plainBody the message as plain text; SMS/WhatsApp send this verbatim
* @param htmlBody rich rendering for channels that support it; may be null
*/
CustomerNotificationResult send(Client client, String subject, String plainBody, String htmlBody);
}
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Email delivery via {@link EmailSenderService} — the direct-SMTP path configured from this app's
* own {@code spring.mail.*} properties, the same one already used for OTP emails and client-change
* alerts.
*
* <p>Deliberately not {@code PlatformEmailService}: that resolves SMTP credentials from the
* DB-backed "external services" table used by the campaign pipeline, which is a separate and
* typically unconfigured surface in this deployment.
*/
@Component
public class EmailCustomerNotificationSender implements CustomerNotificationSender {
private static final Logger LOG = LoggerFactory.getLogger(EmailCustomerNotificationSender.class);
private static final String PROVIDER = "SMTP";
private final EmailSenderService emailSenderService;
public EmailCustomerNotificationSender(final EmailSenderService emailSenderService) {
this.emailSenderService = emailSenderService;
}
@Override
public KycContactChannel getChannel() {
return KycContactChannel.EMAIL;
}
@Override
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
final String to = client.getEmailAddress();
if (StringUtils.isBlank(to)) {
LOG.warn("KYC contact email skipped — client {} has no email address on file.", client.getId());
return CustomerNotificationResult.noDestination(PROVIDER);
}
final String masked = OtpDestinationMasker.maskEmail(to);
try {
final boolean accepted = StringUtils.isNotBlank(htmlBody)
? this.emailSenderService.sendHtml(to, subject, htmlBody)
: this.emailSenderService.sendText(to, subject, plainBody);
if (accepted) {
LOG.info("KYC contact email sent to client {} at {}", client.getId(), masked);
return CustomerNotificationResult.sent(masked, PROVIDER, null);
}
LOG.warn("KYC contact email rejected by SMTP for client {} at {}", client.getId(), masked);
return CustomerNotificationResult.failed(masked, PROVIDER, "SMTP rejected the message");
} catch (final Exception e) {
LOG.error("KYC contact email failed for client {} at {}", client.getId(), masked, e);
return CustomerNotificationResult.failed(masked, PROVIDER, e.getMessage());
}
}
}
@@ -0,0 +1,213 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.client.domain.Client;
import org.slf4j.Logger;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
/**
* Shared HTTP plumbing for the SMS and WhatsApp gateways.
*
* <p><b>Why this is template-driven rather than one class per vendor.</b> Indian SMS/WhatsApp
* gateways (MSG91, Gupshup, Kaleyra, Twilio, LincPay, …) all expose the same shape — POST a
* destination plus a body to a URL with a static API key — but disagree on field names, auth header,
* and whether the payload is JSON or form-encoded. Encoding those three differences as properties
* means switching or adding a vendor is a config change, not a code change, and no vendor's
* credentials end up hard-coded in the repo.
*
* <p>The body template supports these placeholders, substituted with correct escaping for the
* configured content type:
* <ul>
* <li>{@code {{destination}}} — the customer's mobile number</li>
* <li>{@code {{message}}} — the notice text</li>
* <li>{@code {{senderId}}} — the DLT-registered sender ID / WhatsApp source number</li>
* <li>{@code {{templateId}}} — the DLT / WhatsApp template ID, where the vendor requires one</li>
* </ul>
*
* <p>TRAI's DLT regime means a transactional SMS to an Indian number is only accepted when the body
* matches a pre-registered template, so {@code templateId} is exposed even though not every vendor
* needs it.
*/
abstract class RestGatewayNotificationSender implements CustomerNotificationSender {
private final RestTemplate restTemplate = new RestTemplate();
protected abstract Logger log();
/** Human-readable gateway name recorded on the contact log (e.g. {@code MSG91}). */
protected abstract String providerName();
protected abstract boolean isEnabled();
protected abstract String baseUrl();
protected abstract String path();
protected abstract String authHeaderName();
protected abstract String authHeaderValue();
protected abstract String contentType();
protected abstract String bodyTemplate();
protected abstract String senderId();
protected abstract String templateId();
/**
* Dotted path into the JSON response holding the vendor's message ID (e.g.
* {@code data.messageId}). Blank when the vendor returns no usable reference.
*/
protected abstract String referenceJsonPath();
/** Resolve the customer's destination for this channel off the client record. */
protected abstract String resolveDestination(Client client);
/** Mask the destination for storage and logging. */
protected abstract String maskDestination(String destination);
@Override
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
final String provider = providerName();
if (!isEnabled()) {
log().warn("{} notice not sent for client {} — channel disabled in configuration.", provider, client.getId());
return CustomerNotificationResult.notConfigured(provider,
"Channel is disabled — set the corresponding fineract.notification.*.enabled property to true");
}
if (StringUtils.isBlank(baseUrl()) || StringUtils.isBlank(authHeaderValue())) {
log().warn("{} notice not sent for client {} — gateway URL or credential missing.", provider, client.getId());
return CustomerNotificationResult.notConfigured(provider, "Gateway base-url or API credential is not configured");
}
final String destination = resolveDestination(client);
if (StringUtils.isBlank(destination)) {
log().warn("{} notice skipped — client {} has no destination on file for this channel.", provider, client.getId());
return CustomerNotificationResult.noDestination(provider);
}
final String masked = maskDestination(destination);
final boolean json = !MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType());
final String payload = renderBody(destination, plainBody, json);
try {
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(contentType()));
if (StringUtils.isNotBlank(authHeaderName())) {
headers.set(authHeaderName(), authHeaderValue());
}
final String base = baseUrl().endsWith("/") ? baseUrl().substring(0, baseUrl().length() - 1) : baseUrl();
final String url = base + path();
final ResponseEntity<String> response = this.restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
if (response.getStatusCode().is2xxSuccessful()) {
final String reference = extractReference(response.getBody());
log().info("{} notice sent to client {} at {} (ref={})", provider, client.getId(), masked, reference);
return CustomerNotificationResult.sent(masked, provider, reference);
}
// Non-2xx without an exception is unusual for RestTemplate but treat it as a failure
// rather than assuming delivery — an over-optimistic SENT would corrupt the audit trail.
log().warn("{} notice rejected for client {} at {} — HTTP {}", provider, client.getId(), masked, response.getStatusCode());
return CustomerNotificationResult.failed(masked, provider, "Gateway returned HTTP " + response.getStatusCode());
} catch (final Exception e) {
log().error("{} notice failed for client {} at {}", provider, client.getId(), masked, e);
return CustomerNotificationResult.failed(masked, provider, e.getMessage());
}
}
/**
* Substitute the placeholders, escaping each value for the target encoding. Escaping happens per
* value rather than on the finished string so that a message containing a quote or an ampersand
* cannot break out of its field and corrupt the request.
*/
private String renderBody(final String destination, final String message, final boolean json) {
String body = bodyTemplate();
body = body.replace("{{destination}}", escape(destination, json));
body = body.replace("{{message}}", escape(message, json));
body = body.replace("{{senderId}}", escape(StringUtils.defaultString(senderId()), json));
body = body.replace("{{templateId}}", escape(StringUtils.defaultString(templateId()), json));
return body;
}
private static String escape(final String value, final boolean json) {
if (value == null) {
return "";
}
if (!json) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
final StringBuilder out = new StringBuilder(value.length() + 16);
for (int i = 0; i < value.length(); i++) {
final char c = value.charAt(i);
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 0x20) {
out.append(String.format("\\u%04x", (int) c));
} else {
out.append(c);
}
}
}
}
return out.toString();
}
/** Walk {@link #referenceJsonPath()} through the response body; null if absent or unparseable. */
private String extractReference(final String responseBody) {
final String jsonPath = referenceJsonPath();
if (StringUtils.isBlank(jsonPath) || StringUtils.isBlank(responseBody)) {
return null;
}
try {
JsonElement node = JsonParser.parseString(responseBody);
for (final String segment : jsonPath.split("\\.")) {
if (node == null || !node.isJsonObject()) {
return null;
}
final JsonObject object = node.getAsJsonObject();
if (!object.has(segment)) {
return null;
}
node = object.get(segment);
}
return node != null && node.isJsonPrimitive() ? node.getAsString() : null;
} catch (final Exception e) {
log().debug("Could not read reference '{}' from {} response", jsonPath, providerName(), e);
return null;
}
}
}
@@ -0,0 +1,151 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Plain-text SMS via the configured gateway. See {@link RestGatewayNotificationSender} for why the
* request shape is property-driven.
*
* <p>Note this is a <em>separate</em> gateway from the LincPay eKYC endpoints already configured
* for login OTP ({@code /mobile/generate-otp}). That endpoint generates and delivers an OTP of
* LincPay's own choosing; it cannot carry an arbitrary message, and routing a KYC notice through it
* would mint a real, verifiable OTP the customer never requested. Configure a genuine
* send-message endpoint here.
*/
@Component
public class SmsCustomerNotificationSender extends RestGatewayNotificationSender {
private static final Logger LOG = LoggerFactory.getLogger(SmsCustomerNotificationSender.class);
@Value("${fineract.notification.sms.enabled:false}")
private boolean enabled;
@Value("${fineract.notification.sms.provider:SMS_GATEWAY}")
private String provider;
@Value("${fineract.notification.sms.base-url:}")
private String baseUrl;
@Value("${fineract.notification.sms.path:}")
private String path;
@Value("${fineract.notification.sms.auth-header-name:Authorization}")
private String authHeaderName;
@Value("${fineract.notification.sms.auth-header-value:}")
private String authHeaderValue;
@Value("${fineract.notification.sms.content-type:application/json}")
private String contentType;
@Value("${fineract.notification.sms.body-template:}")
private String bodyTemplate;
@Value("${fineract.notification.sms.sender-id:}")
private String senderId;
@Value("${fineract.notification.sms.template-id:}")
private String templateId;
@Value("${fineract.notification.sms.reference-json-path:}")
private String referenceJsonPath;
@Override
public KycContactChannel getChannel() {
return KycContactChannel.SMS;
}
@Override
protected Logger log() {
return LOG;
}
@Override
protected String providerName() {
return this.provider;
}
@Override
protected boolean isEnabled() {
return this.enabled;
}
@Override
protected String baseUrl() {
return this.baseUrl;
}
@Override
protected String path() {
return this.path;
}
@Override
protected String authHeaderName() {
return this.authHeaderName;
}
@Override
protected String authHeaderValue() {
return this.authHeaderValue;
}
@Override
protected String contentType() {
return this.contentType;
}
@Override
protected String bodyTemplate() {
return this.bodyTemplate;
}
@Override
protected String senderId() {
return this.senderId;
}
@Override
protected String templateId() {
return this.templateId;
}
@Override
protected String referenceJsonPath() {
return this.referenceJsonPath;
}
@Override
protected String resolveDestination(final Client client) {
return client.getMobileNo();
}
@Override
protected String maskDestination(final String destination) {
return OtpDestinationMasker.maskMobile(destination);
}
}
@@ -0,0 +1,179 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.notification;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* WhatsApp Business delivery via the configured gateway (Meta Cloud API, Gupshup, Twilio, …).
*
* <p>Reuses the customer's registered mobile number as the WhatsApp destination — banks do not
* capture a separate WhatsApp handle, and the Business API is keyed on the phone number anyway.
* Most providers want it in E.164 form, so {@code fineract.notification.whatsapp.country-code} is
* prefixed to bare 10-digit Indian numbers.
*
* <p>Meta's policy only permits business-initiated messages against an approved template outside the
* 24-hour customer service window, which a KYC exception notice always is — so
* {@code template-id} will normally need to be set alongside a template-shaped body.
*/
@Component
public class WhatsAppCustomerNotificationSender extends RestGatewayNotificationSender {
private static final Logger LOG = LoggerFactory.getLogger(WhatsAppCustomerNotificationSender.class);
@Value("${fineract.notification.whatsapp.enabled:false}")
private boolean enabled;
@Value("${fineract.notification.whatsapp.provider:WHATSAPP_GATEWAY}")
private String provider;
@Value("${fineract.notification.whatsapp.base-url:}")
private String baseUrl;
@Value("${fineract.notification.whatsapp.path:}")
private String path;
@Value("${fineract.notification.whatsapp.auth-header-name:Authorization}")
private String authHeaderName;
@Value("${fineract.notification.whatsapp.auth-header-value:}")
private String authHeaderValue;
@Value("${fineract.notification.whatsapp.content-type:application/json}")
private String contentType;
@Value("${fineract.notification.whatsapp.body-template:}")
private String bodyTemplate;
@Value("${fineract.notification.whatsapp.sender-id:}")
private String senderId;
@Value("${fineract.notification.whatsapp.template-id:}")
private String templateId;
@Value("${fineract.notification.whatsapp.reference-json-path:}")
private String referenceJsonPath;
/** Prefixed to bare national numbers to reach E.164. Blank disables normalisation. */
@Value("${fineract.notification.whatsapp.country-code:91}")
private String countryCode;
@Override
public KycContactChannel getChannel() {
return KycContactChannel.WHATSAPP;
}
@Override
protected Logger log() {
return LOG;
}
@Override
protected String providerName() {
return this.provider;
}
@Override
protected boolean isEnabled() {
return this.enabled;
}
@Override
protected String baseUrl() {
return this.baseUrl;
}
@Override
protected String path() {
return this.path;
}
@Override
protected String authHeaderName() {
return this.authHeaderName;
}
@Override
protected String authHeaderValue() {
return this.authHeaderValue;
}
@Override
protected String contentType() {
return this.contentType;
}
@Override
protected String bodyTemplate() {
return this.bodyTemplate;
}
@Override
protected String senderId() {
return this.senderId;
}
@Override
protected String templateId() {
return this.templateId;
}
@Override
protected String referenceJsonPath() {
return this.referenceJsonPath;
}
@Override
protected String resolveDestination(final Client client) {
return toE164(client.getMobileNo());
}
@Override
protected String maskDestination(final String destination) {
return OtpDestinationMasker.maskMobile(destination);
}
/**
* Strip formatting and prefix the country code when the number is stored nationally. A number
* that already carries a country code (leading {@code +}, or longer than 10 digits) is passed
* through so we never double-prefix.
*/
private String toE164(final String mobileNo) {
if (mobileNo == null || mobileNo.isBlank()) {
return null;
}
final String digits = mobileNo.replaceAll("[^0-9+]", "");
if (digits.isBlank()) {
return null;
}
if (digits.startsWith("+")) {
return digits.substring(1);
}
if (this.countryCode == null || this.countryCode.isBlank() || digits.length() > 10) {
return digits;
}
return this.countryCode + digits;
}
}
@@ -76,8 +76,16 @@ public class ClientKycCommandFromApiJsonDeserializer {
final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed(
ClientKycApiConstants.aadhaarOtpParam, element);
final Boolean isException = this.fromApiJsonHelper.extractBooleanNamed(
ClientKycApiConstants.isExceptionParam, element);
final String exceptionReason = this.fromApiJsonHelper.extractStringNamed(
ClientKycApiConstants.exceptionReasonParam, element);
final Boolean exceptionResolved = this.fromApiJsonHelper.extractBooleanNamed(
ClientKycApiConstants.exceptionResolvedParam, element);
return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked,
gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin,
aadhaarNumber, aadhaarRefNumber, aadhaarOtp);
aadhaarNumber, aadhaarRefNumber, aadhaarOtp, isException, exceptionReason,
exceptionResolved);
}
}
@@ -18,79 +18,42 @@
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
* <ul>
* <li><b>TT</b> — Customer type code:
* <code>11</code> Individual, <code>22</code> Corporate, <code>33</code> Minor, <code>44</code> Staff.
* Priority order when multiple flags overlap: <em>Staff &gt; Minor &gt; Corporate &gt; Individual</em>.</li>
* <li><b>YYYY</b> — Year the client was submitted (4 digits).</li>
* <li><b>NNNNNN</b> — Zero-padded 6-digit sequence within the (type, year) bucket.</li>
* </ul>
* Generates CIF numbers in the format <code>CIF</code> followed by the client's id
* zero-padded to 10 digits — e.g. client 123 → <code>CIF0000000123</code>.
*
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
* The generator handles concurrent inserts by retrying on uniqueness collisions
* (the caller's outer save is what actually triggers the unique constraint).
* <p>
* Uniqueness is structural rather than probabilistic: <code>m_client.id</code> is a single sequence
* shared by every office, so two clients booked concurrently at different branches can never derive
* the same CIF. That makes the number globally unique across the whole institution, not just within
* the branch that created it, and removes the read-then-write race a "count existing + 1" scheme has.
* The <code>cif_number_unique</code> index on m_client remains the backstop.
* </p>
*
* <p>
* The client must already be persisted when this is called, since the id is the source of the number.
* </p>
*/
@Component
@RequiredArgsConstructor
public class CifNumberGenerator {
private static final String TYPE_INDIVIDUAL = "11";
private static final String TYPE_CORPORATE = "22";
private static final String TYPE_MINOR = "33";
private static final String TYPE_STAFF = "44";
private static final String CIF_PREFIX = "CIF";
private static final int SEQUENCE_WIDTH = 6;
private static final int MAX_COLLISION_RETRIES = 5;
private final ClientRepository clientRepository;
/** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */
private static final int SEQUENCE_WIDTH = 10;
/**
* Generate the next CIF number for the given client. Caller is responsible for persisting.
* Generate the CIF number for the given client. Caller is responsible for persisting.
*/
public String generate(final Client client) {
final String typeCode = resolveTypeCode(client);
final int year = resolveYear(client);
final String prefix = typeCode + year;
long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1;
for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0');
if (clientRepository.countByCifNumberPrefix(candidate) == 0) {
return candidate;
}
nextSequence++;
final Long clientId = client.getId();
if (clientId == null) {
throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted");
}
// Last resort: append client id to guarantee uniqueness if every attempt collided
return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0');
}
private String resolveTypeCode(final Client client) {
if (client.isStaff()) {
return TYPE_STAFF;
}
if (Boolean.TRUE.equals(client.getIsMinor())) {
return TYPE_MINOR;
}
final Integer legalForm = client.getLegalForm();
if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) {
return TYPE_CORPORATE;
}
return TYPE_INDIVIDUAL;
}
private int resolveYear(final Client client) {
final LocalDate submittedOn = client.getSubmittedOnDate();
return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear();
return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0');
}
}
@@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService {
* Retrieve the KYC record for a client.
*/
ClientKycData retrieveClientKycByClientId(Long clientId);
java.util.List<ClientKycData> retrieveActiveKycExceptions();
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.client.service;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.data.ClientKycData;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
@@ -55,10 +57,19 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue());
final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue());
final String mobileNo = kyc.getClient().getMobileNo();
final String emailAddress = kyc.getClient().getEmailAddress();
return ClientKycData.builder()
.id(kyc.getId())
.clientId(kyc.getClient().getId())
.clientDisplayName(kyc.getClient().getDisplayName())
.clientAccountNo(kyc.getClient().getAccountNumber())
.clientOfficeName(kyc.getClient().getOffice().getName())
.clientMobileMasked(StringUtils.isNotBlank(mobileNo) ? OtpDestinationMasker.maskMobile(mobileNo) : null)
.clientEmailMasked(StringUtils.isNotBlank(emailAddress) ? OtpDestinationMasker.maskEmail(emailAddress) : null)
.hasMobile(StringUtils.isNotBlank(mobileNo))
.hasEmail(StringUtils.isNotBlank(emailAddress))
.kycStatus(status.getValue())
.kycStatusCode(status.getCode())
.kycType(type.getValue())
@@ -74,14 +85,27 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
.panCategory(kyc.getPanCategory())
.panStatus(kyc.getPanStatus())
.panDeclarationTaken(kyc.getPanDeclarationTaken())
.panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields())
.panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan())
.panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan())
.panDeclarationRemarks(kyc.getPanDeclarationRemarks())
.panDeclarationTakenOn(kyc.getPanDeclarationTakenOn())
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
.aadhaarVerified(kyc.isAadhaarVerified())
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
.aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken())
.aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId())
.aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate())
.aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks())
.aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn())
.gstinNumber(kyc.getGstinNumber())
.gstinVerified(kyc.isGstinVerified())
.gstinVerifiedOn(kyc.getGstinVerifiedOn())
.enterprisePan(kyc.getEnterprisePan())
.cinNumber(kyc.getCinNumber())
.cinVerified(kyc.isCinVerified())
.cinVerifiedOn(kyc.getCinVerifiedOn())
.incorporationDate(kyc.getIncorporationDate())
.kycDocumentName(kyc.getKycDocumentName())
.kycDocumentNumber(kyc.getKycDocumentNumber())
@@ -95,6 +119,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.accountStatus(kyc.getAccountStatus())
.freezeReason(kyc.getFreezeReason())
.freezeAuthority(kyc.getFreezeAuthority())
.freezeDate(kyc.getFreezeDate())
.freezeAmount(kyc.getFreezeAmount())
.unfreezeDate(kyc.getUnfreezeDate())
.noDebitDate(kyc.getNoDebitDate())
.form60Type(kyc.getForm60Type())
.form60Date(kyc.getForm60Date())
.form15Applicable(kyc.getForm15Applicable())
@@ -103,6 +131,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.ckycNumber(kyc.getCkycNumber())
.ckycUploadedOn(kyc.getCkycUploadedOn())
.ckycBatchId(kyc.getCkycBatchId())
.isException(kyc.getIsException())
.exceptionReason(kyc.getExceptionReason())
.exceptionResolved(kyc.getExceptionResolved())
.exceptionResolvedOn(kyc.getExceptionResolvedOn())
.initiatedOn(kyc.getInitiatedOn())
.verifiedOn(kyc.getVerifiedOn())
.rejectedOn(kyc.getRejectedOn())
@@ -110,4 +142,18 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.remarks(kyc.getRemarks())
.build();
}
@Override
public java.util.List<ClientKycData> retrieveActiveKycExceptions() {
return this.kycRepository.findByIsExceptionTrueAndExceptionResolvedFalse().stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
@Override
public java.util.List<ClientKycData> retrieveResolvedKycExceptions() {
return this.kycRepository.findByExceptionResolvedTrue().stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
}
@@ -158,27 +158,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final Map<String, Object> changes = kyc.update(command);
if (!changes.isEmpty()) {
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
final boolean panChanged = changes.containsKey("panNumber");
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
final boolean gstinChanged = changes.containsKey("gstinNumber");
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
if (panChanged && currentStatus.isVerified()) {
kyc.resetPanVerification();
changes.put("panVerified", false);
}
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
}
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
// Handles the common case of editing remarks / risk on a fully-verified KYC.
kyc.tryAutoVerify();
if (kyc.getKycStatus().isVerified()) {
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
}
kycRepositoryWrapper.saveAndFlush(kyc);
}
@@ -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
}
// Same audit semantics as the explicit updateKyc endpoint:
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
final boolean panChanged = changes.containsKey("panNumber");
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
final boolean gstinChanged = changes.containsKey("gstinNumber");
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
if (currentStatus.isVerified() && panChanged) {
kyc.resetPanVerification();
}
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
}
kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
@@ -619,9 +619,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientRepository.saveAndFlush(newClient);
}
// CIF Number generation — TTYYYYNNNNNN format. TT picked from client type
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate,
// NNNNNN is the next 6-digit sequence within the (type, year) bucket.
// CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123.
// Must run after the saveAndFlush above, since the id is what the number is derived from;
// deriving it from the shared id sequence is what keeps CIFs unique across all branches.
if (StringUtils.isBlank(cifNumber)) {
newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
this.clientRepository.saveAndFlush(newClient);
@@ -1466,7 +1466,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
validateParentGroupRulesBeforeClientActivation(client);
this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client);
// Mother/Father-or-Caretaker is enforced where family members are captured (the Family
// Details step), not here: a client can reach approval through several different forms,
// and gating activation on it left approvers unable to approve what was handed to them.
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.util.List;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
/**
* Records — and, for the sending channels, actually transmits — a KYC officer's outreach to a
* customer with an open KYC exception.
*/
public interface KycContactLogService {
/**
* Log one contact attempt. For {@code SMS}/{@code EMAIL}/{@code WHATSAPP} this also sends the
* notice to the customer; for the offline channels it only records what the officer already did.
*
* <p>The log row is persisted whether or not delivery succeeded — a gateway outage must not
* discard the officer's notes.
*
* @param clientId customer the exception belongs to
* @param rawChannel channel as submitted by the UI (label or enum name)
* @param notes officer's summary; mandatory
* @param officerName officer selected in the dialog
* @return the persisted log, including its delivery outcome
*/
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
/** All contact attempts for one customer, oldest first. */
List<KycContactLogData> retrieveForClient(Long clientId);
}
@@ -0,0 +1,206 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycContactLog;
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
import org.apache.fineract.portfolio.client.notification.CustomerNotificationResult;
import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class KycContactLogServiceImpl implements KycContactLogService {
private static final Logger LOG = LoggerFactory.getLogger(KycContactLogServiceImpl.class);
private static final int NOTES_MAX_LENGTH = 1000;
@Value("${fineract.email.from-name:CBSnexora}")
private String bankName;
/** Days the customer is given to act, quoted in the notice body. */
@Value("${fineract.notification.kyc-contact.response-days:15}")
private int responseDays;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final ClientKycRepository kycRepository;
private final ClientKycContactLogRepository contactLogRepository;
private final CustomerNotificationDispatcher dispatcher;
private final EmailTemplateService emailTemplateService;
private final PlatformSecurityContext context;
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
this.clientRepositoryWrapper = clientRepositoryWrapper;
this.kycRepository = kycRepository;
this.contactLogRepository = contactLogRepository;
this.dispatcher = dispatcher;
this.emailTemplateService = emailTemplateService;
this.context = context;
}
@Override
@Transactional
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
if (channel == null) {
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
}
if (StringUtils.isBlank(notes)) {
throw validationError("notes", "A summary of the contact is required.");
}
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
final AppUser currentUser = this.context.authenticatedUser();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
if (channel.isPlatformSent()) {
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
final String plainBody = buildPlainBody(client, exceptionReason, channel);
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
final String htmlBody = channel == KycContactChannel.EMAIL
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
: null;
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
result.failureReason(), plainBody);
if (!result.isDelivered()) {
// Logged, not thrown: the officer's notes are still worth persisting, and the UI
// surfaces the failure so nobody assumes the customer was reached.
LOG.warn("KYC contact notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel,
result.status(), result.failureReason());
}
}
final ClientKycContactLog saved = this.contactLogRepository.saveAndFlush(log);
return toData(saved);
}
@Override
@Transactional(readOnly = true)
public List<KycContactLogData> retrieveForClient(final Long clientId) {
return this.contactLogRepository.findByClientIdOrderByContactedAtAsc(clientId).stream().map(this::toData).toList();
}
/**
* The notice text. Kept short and channel-aware because an SMS over 160 GSM-7 characters is
* billed and delivered as multiple segments; email can afford the longer framing but reuses the
* same sentence so the audit trail shows one consistent message across channels.
*/
private String buildPlainBody(final Client client, final String exceptionReason, final KycContactChannel channel) {
final String reason = StringUtils.isNotBlank(exceptionReason) ? exceptionReason : "KYC verification is incomplete";
if (channel == KycContactChannel.SMS) {
return "Dear " + displayName(client) + ", your KYC is pending: " + reason + ". Please visit your branch with valid "
+ "documents within " + this.responseDays + " days to avoid account restrictions. - " + this.bankName;
}
return "Dear " + displayName(client) + ", our records show that your KYC (Know Your Customer) verification is pending "
+ "for the following reason: " + reason + ". As required by the Reserve Bank of India's KYC Master Direction, "
+ "please visit your home branch with valid identity and address documents within " + this.responseDays
+ " days. If this is not completed, operations on your account may be restricted. "
+ "If you have already submitted these documents, please ignore this message. - " + this.bankName;
}
private KycContactLogData toData(final ClientKycContactLog log) {
final KycContactChannel channel = log.getChannel();
final KycContactDeliveryStatus status = log.getDeliveryStatus();
return KycContactLogData.builder()
.id(log.getId())
.clientId(log.getClient().getId())
.kycId(log.getKycId())
.channel(channel.name())
.channelLabel(channel.getDisplayLabel())
.notes(log.getNotes())
.officerName(log.getOfficerName())
.exceptionReason(log.getExceptionReason())
.deliveryStatus(status.name())
.delivered(status.isDelivered())
.platformSent(channel.isPlatformSent())
.deliveryMessage(describeDelivery(channel, status, log.getDestinationMasked(), log.getFailureReason()))
.destinationMasked(log.getDestinationMasked())
.provider(log.getProvider())
.providerReference(log.getProviderReference())
.messageBody(log.getMessageBody())
.contactedAt(log.getContactedAt())
.build();
}
/** Officer-facing outcome sentence. Failures name the cause so the next action is obvious. */
private String describeDelivery(final KycContactChannel channel, final KycContactDeliveryStatus status, final String destination,
final String failureReason) {
return switch (status) {
case NOT_APPLICABLE -> channel.getDisplayLabel() + " recorded.";
case SENT -> channel.getDisplayLabel() + " sent to " + (StringUtils.isNotBlank(destination) ? destination : "the customer")
+ ".";
case SKIPPED_NO_DESTINATION -> "Not sent — the customer has no "
+ (channel == KycContactChannel.EMAIL ? "email address" : "mobile number") + " on file.";
case PROVIDER_NOT_CONFIGURED -> "Not sent — no " + channel.getDisplayLabel()
+ " gateway is configured. The attempt has been logged.";
case FAILED -> "Not sent — the gateway rejected the message"
+ (StringUtils.isNotBlank(failureReason) ? " (" + failureReason + ")" : "") + ". The attempt has been logged.";
};
}
private String displayName(final Client client) {
if (StringUtils.isNotBlank(client.getFirstname())) {
return client.getFirstname();
}
if (StringUtils.isNotBlank(client.getDisplayName())) {
return client.getDisplayName();
}
return "Customer";
}
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.contact.log." + parameter + ".invalid",
message, parameter);
return new PlatformApiDataValidationException(List.of(error));
}
}
@@ -23,4 +23,7 @@ import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
public interface KycRiskRulesReadPlatformService {
List<KycRiskRuleData> retrieveAllRules();
/** One rule with its live-transaction counterpart resolved, as returned after a create. */
KycRiskRuleData retrieveRule(Long ruleId);
}
@@ -48,6 +48,15 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
}
@Override
public KycRiskRuleData retrieveRule(final Long ruleId) {
final ClientRiskRule rule = this.repository.findById(ruleId)
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
final AmlTransactionRule txnRule = this.amlTransactionRuleRepository.findAll().stream()
.filter(txn -> ruleId.equals(txn.getSourceClientRiskRuleId())).findFirst().orElse(null);
return mapToData(rule, txnRule);
}
private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
public interface KycRiskRulesWritePlatformService {
/**
* Adds a rule to the AML catalog. Creates one {@code m_client_risk_rule} row and, when the
* request describes a transaction band or observation window, a companion
* {@code m_aml_transaction_rule} row linked back to it.
*
* @return the persisted rule exactly as the catalog GET would return it
*/
KycRiskRuleData createRule(KycRiskRuleRequest request);
}
@@ -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
final int batchSize = 100;
for (int i = 0; i < allEntries.size(); i += batchSize) {
@@ -647,7 +647,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
}
private boolean isWithHoldTaxApplicableForInterestPosting(final SavingsAccountData savingsAccountData) {
return this.withHoldTax(savingsAccountData) && this.depositAccountType(savingsAccountData).isSavingsDeposit();
return false;
}
private boolean withHoldTax(final SavingsAccountData savingsAccountData) {
@@ -67,6 +67,10 @@ public class ShareAccountData implements Serializable, AccountData {
private Long savingsAccountId;
private Long linkedLoanId;
private String linkedLoanAccountNo;
private BigDecimal currentMarketPrice;
private Integer lockinPeriod;
@@ -301,6 +305,11 @@ public class ShareAccountData implements Serializable, AccountData {
this.currentMarketPrice = currentMarketPrice;
}
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
this.linkedLoanId = linkedLoanId;
this.linkedLoanAccountNo = linkedLoanAccountNo;
}
public void setDividends(Collection<ShareAccountDividendData> dividends) {
this.dividends = dividends;
}
@@ -51,4 +51,10 @@ public class ShareAccountTransactionData implements Serializable {
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;
}
@@ -147,6 +147,12 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccount", orphanRemoval = true, fetch = FetchType.EAGER)
private Set<ShareAccountCharge> charges;
/** Optional loan this share account is held as margin/security against. A raw id (not a JPA
* relation) since shareaccounts and loanaccount are otherwise unrelated aggregate roots in
* this codebase — kept loosely coupled, resolved via a lookup where needed. */
@Column(name = "linked_loan_id")
private Long linkedLoanId;
@Transient
protected boolean accountNumberRequiresAutoGeneration = false;
@@ -599,6 +605,19 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
return this.activatedDate;
}
public Long getLinkedLoanId() {
return this.linkedLoanId;
}
public boolean setLinkedLoanId(final Long linkedLoanId) {
boolean returnValue = false;
if (this.linkedLoanId == null || !this.linkedLoanId.equals(linkedLoanId)) {
this.linkedLoanId = linkedLoanId;
returnValue = true;
}
return returnValue;
}
public Integer status() {
return this.status;
}
@@ -67,6 +67,14 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
@Column(name = "is_active", nullable = false)
private boolean active = true;
/** Board Resolution Number and Date of Meeting authorising this transaction — required for a
* share withdrawal (redeem), a governance requirement for cooperative-sector share capital. */
@Column(name = "resolution_number")
private String resolutionNumber;
@Column(name = "meeting_date")
private LocalDate meetingDate;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
@@ -109,6 +117,22 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
return new ShareAccountTransaction(transactionDate, totalShares, shareValue, status, type, amount, null, amountPaid);
}
public static ShareAccountTransaction createRedeemTransaction(final LocalDate transactionDate, final Long totalShares,
final BigDecimal shareValue, final String resolutionNumber, final LocalDate meetingDate) {
ShareAccountTransaction transaction = createRedeemTransaction(transactionDate, totalShares, shareValue);
transaction.resolutionNumber = resolutionNumber;
transaction.meetingDate = meetingDate;
return transaction;
}
public String getResolutionNumber() {
return this.resolutionNumber;
}
public LocalDate getMeetingDate() {
return this.meetingDate;
}
public static ShareAccountTransaction createChargeTransaction(final LocalDate transactionDate, final ShareAccountCharge charge) {
final Long totalShares = null;
final BigDecimal unitPrice = null;
@@ -72,6 +72,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
@@ -93,6 +94,8 @@ public class ShareAccountDataSerializer {
private final SavingsAccountReadPlatformService savingsAccountReadPlatformService;
private final JdbcTemplate jdbcTemplate;
private static final Set<String> approvalParameters = new HashSet<>(
Arrays.asList(ShareAccountApiConstants.locale_paramname, ShareAccountApiConstants.dateformat_paramname,
ShareAccountApiConstants.approveddate_paramname, ShareAccountApiConstants.note_paramname));
@@ -106,13 +109,14 @@ public class ShareAccountDataSerializer {
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname));
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname));
@Autowired
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
final ChargeRepositoryWrapper chargeRepository, final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
final ClientRepositoryWrapper clientRepositoryWrapper, final ShareProductRepositoryWrapper shareProductRepository,
final SavingsAccountReadPlatformService savingsAccountReadPlatformService) {
final SavingsAccountReadPlatformService savingsAccountReadPlatformService, final JdbcTemplate jdbcTemplate) {
this.platformSecurityContext = platformSecurityContext;
this.fromApiJsonHelper = fromApiJsonHelper;
this.chargeRepository = chargeRepository;
@@ -120,6 +124,7 @@ public class ShareAccountDataSerializer {
this.clientRepositoryWrapper = clientRepositoryWrapper;
this.shareProductRepository = shareProductRepository;
this.savingsAccountReadPlatformService = savingsAccountReadPlatformService;
this.jdbcTemplate = jdbcTemplate;
}
public ShareAccount validateAndCreate(JsonCommand jsonCommand) {
@@ -313,6 +318,13 @@ public class ShareAccountDataSerializer {
}
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) {
final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element);
if (account.setLinkedLoanId(linkedLoanId)) {
actualChanges.put(ShareAccountApiConstants.linkedloanid_paramname, linkedLoanId);
}
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.savingsaccountid_paramname, element)) {
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull()
@@ -885,6 +897,13 @@ public class ShareAccountDataSerializer {
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(sharesRequested).notNull()
.longGreaterThanZero();
// Governance requirement: a share withdrawal must be authorised by a Board Resolution.
final String resolutionNumber = this.fromApiJsonHelper
.extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank();
final LocalDate meetingDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname,
element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull();
boolean isTransactionBeforeExistingTransactions = false;
isTransactionBeforeExistingTransactions = isTransactionBeforeExistingTransactions(requestedDate,
isTransactionBeforeExistingTransactions, account);
@@ -896,7 +915,8 @@ public class ShareAccountDataSerializer {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(requestedDate);
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice);
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice,
resolutionNumber, meetingDate);
validateRedeemRequest(account, transaction, baseDataValidator, dataValidationErrors);
account.addAdditionalPurchasedShares(transaction);
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
@@ -917,6 +937,18 @@ public class ShareAccountDataSerializer {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
final Long remainingShares = account.getTotalApprovedShares() - redeemTransaction.getTotalShares();
validateAgainstLinkedLoan(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
validateMinimumSharesAndTenure(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
LocalDate redeemDate = redeemTransaction.getPurchasedDate();
final Integer lockinPeriod = account.getLockinPeriodFrequency();
final PeriodFrequencyType periodType = account.getLockinPeriodFrequencyType();
@@ -967,6 +999,63 @@ public class ShareAccountDataSerializer {
}
}
/** If this share account is held as margin/security against a loan, the shares remaining
* after this redemption (at current unit price) must still cover that loan's outstanding
* balance. */
private void validateAgainstLinkedLoan(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List<ApiParameterError> dataValidationErrors) {
final Long linkedLoanId = account.getLinkedLoanId();
if (linkedLoanId == null) {
return;
}
BigDecimal outstandingLoanBalance;
try {
outstandingLoanBalance = this.jdbcTemplate.queryForObject(
"SELECT COALESCE(total_outstanding_derived, principal_disbursed_derived, 0) FROM m_loan WHERE id = ? AND loan_status_id = 300",
BigDecimal.class, linkedLoanId);
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
// Linked loan is no longer active (closed/written-off) — nothing left to protect.
outstandingLoanBalance = null;
}
if (outstandingLoanBalance == null) {
return;
}
final BigDecimal remainingShareValue = redeemTransaction.getPurchasePrice().multiply(BigDecimal.valueOf(remainingShares));
if (remainingShareValue.compareTo(outstandingLoanBalance) < 0) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname)
.value(redeemTransaction.getTotalShares())
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.shares.linked.to.active.loan.margin.shortfall");
}
}
/** If this redemption would bring the holding below the Share Product's configured minimum
* client shares, the account must have been active for at least the product's configured
* minimum active period before the reduction is allowed. */
private void validateMinimumSharesAndTenure(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List<ApiParameterError> dataValidationErrors) {
final Long minimumClientShares = account.getShareProduct().getMinimumClientShares();
if (minimumClientShares == null || remainingShares >= minimumClientShares) {
return;
}
final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency();
final PeriodFrequencyType minimumActivePeriodType = account.getMinimumActivePeriodFrequencyType();
final LocalDate activatedDate = account.getActivatedDate();
if (minimumActivePeriod == null || minimumActivePeriodType == null || activatedDate == null) {
// Product enforces a minimum balance but defines no minimum tenure — reducing below
// minimum is simply not allowed at all in that case.
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname)
.value(redeemTransaction.getTotalShares())
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.required.by.product");
return;
}
final LocalDate eligibleFromDate = deriveLockinPeriodDuration(minimumActivePeriod, minimumActivePeriodType, activatedDate);
if (DateUtils.isAfter(eligibleFromDate, redeemTransaction.getPurchasedDate())) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname)
.value(redeemTransaction.getPurchasedDate())
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.before.minimum.tenure");
}
}
private LocalDate deriveLockinPeriodDuration(final Integer lockinPeriod, final PeriodFrequencyType periodType, LocalDate purchaseDate) {
LocalDate lockinDate = purchaseDate;
if (periodType != null) {
@@ -1044,14 +1133,65 @@ public class ShareAccountDataSerializer {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
validateNoActiveLoanOrGuaranteeBeforeClosure(account, baseDataValidator, dataValidationErrors);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
AppUser approvedUser = this.platformSecurityContext.authenticatedUser();
final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate());
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, account.getTotalApprovedShares(),
unitPrice);
account.addAdditionalPurchasedShares(transaction);
// Only synthesize a redeem transaction (and its GL posting) when shares actually remain to
// be redeemed — an account closed after every share was already withdrawn has nothing left
// to force-redeem, and posting a zero-share/zero-value transaction just clutters the GL with
// a meaningless ₹0.00/₹0.00 entry pair.
final Long remainingShares = account.getTotalApprovedShares();
if (remainingShares != null && remainingShares > 0) {
final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate());
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, remainingShares, unitPrice);
account.addAdditionalPurchasedShares(transaction);
handleRedeemSharesChargeTransactions(account, transaction);
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
}
account.close(closedDate, approvedUser);
handleRedeemSharesChargeTransactions(account, transaction);
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
return actualChanges;
}
/** A share account cannot be closed while the member has an active loan (share capital is
* often required as continuing membership/security while a liability is outstanding) or is
* standing as an active guarantor on someone else's active loan. */
private void validateNoActiveLoanOrGuaranteeBeforeClosure(final ShareAccount account, final DataValidatorBuilder baseDataValidator,
final List<ApiParameterError> dataValidationErrors) {
final Long clientId = account.getClientId();
Integer activeLoanCount;
try {
activeLoanCount = this.jdbcTemplate.queryForObject(
"SELECT COUNT(id) FROM m_loan WHERE client_id = ? AND loan_status_id IN (300, 303, 304, 700)", Integer.class,
clientId);
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
activeLoanCount = 0;
}
if (activeLoanCount != null && activeLoanCount > 0) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname)
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.loan");
return;
}
Integer activeGuaranteeCount;
try {
activeGuaranteeCount = this.jdbcTemplate.queryForObject("""
SELECT COUNT(g.id)
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)
""", Integer.class, clientId);
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
activeGuaranteeCount = 0;
}
if (activeGuaranteeCount != null && activeGuaranteeCount > 0) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname)
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.guarantee");
}
}
}
@@ -50,7 +50,8 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
StringBuilder buff = new StringBuilder()
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
.append("saps.amount_paid as amountPaid").append(" from m_share_account_transactions saps ");
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate")
.append(" from m_share_account_transactions saps ");
schema = buff.toString();
}
@@ -68,9 +69,12 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amount");
final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "chargeamount");
final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amountPaid");
final String resolutionNumber = rs.getString("resolutionNumber");
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
amount, chargeAmount, amountPaid);
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
}
public String schema() {
@@ -267,6 +267,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
this.purchasedShares = purchasedShares;
StringBuilder buff = new StringBuilder().append(
"sa.id as id, sa.external_id as externalId, sa.resolution_number as resolutionNumber, sa.status_enum as statusEnum, ")
.append("sa.linked_loan_id as linkedLoanId, ml.account_no as linkedLoanAccountNo, ")
.append("sa.savings_account_id, msa.account_no as savingsAccNo, ")
.append("c.id as clientId, c.display_name as clientName, ")
.append("sa.account_no as accountNo, sa.total_approved_shares as approvedShares, sa.total_pending_shares as pendingShares, ")
@@ -294,7 +295,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
.append("left join m_appuser abu on abu.id = sa.approved_userid ")
.append("left join m_appuser avbu on rbu.id = sa.activated_userid ")
.append("left join m_appuser cbu on cbu.id = sa.closed_userid ")
.append("left join m_savings_account msa on sa.savings_account_id = msa.id ");
.append("left join m_savings_account msa on sa.savings_account_id = msa.id ")
.append("left join m_loan ml on ml.id = sa.linked_loan_id ");
this.schema = buff.toString();
}
@@ -304,6 +306,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final String accountNo = rs.getString("accountNo");
final String externalId = rs.getString("externalId");
final String resolutionNumber = rs.getString("resolutionNumber");
final Long linkedLoanId = JdbcSupport.getLong(rs, "linkedLoanId");
final String linkedLoanAccountNo = rs.getString("linkedLoanAccountNo");
final Long savingsAccountId = JdbcSupport.getLong(rs, "savings_account_id");
final String savingsAccountNumber = rs.getString("savingsAccNo");
final Long clientId = JdbcSupport.getLong(rs, "clientId");
@@ -375,10 +379,12 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final String shortProductName = null;
final ShareAccountSummaryData summary = new ShareAccountSummaryData(id, accountNo, externalId, productId, productName,
shortProductName, status, currency, totalApprovedShares, totalPendingShares, timeline);
return new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, savingsAccountNumber, clientId,
clientName, productId, productName, status, timeline, currency, summary, charges, purchasedShares,
lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType,
final ShareAccountData shareAccountData = new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId,
savingsAccountNumber, clientId, clientName, productId, productName, status, timeline, currency, summary, charges,
purchasedShares, lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType,
allowdividendsforinactiveclients);
shareAccountData.setLinkedLoan(linkedLoanId, linkedLoanAccountNo);
return shareAccountData;
}
@@ -461,7 +467,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
StringBuilder buff = new StringBuilder().append(
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
.append("saps.amount_paid as amountPaid ");
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate ");
schema = buff.toString();
}
@@ -480,8 +486,11 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amount");
final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeamount");
final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amountPaid");
final String resolutionNumber = rs.getString("resolutionNumber");
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
amount, chargeAmount, amountPaid);
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
}
public String schema() {
@@ -484,23 +484,25 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
try {
ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
Map<String, Object> changes = this.accountDataSerializer.validateAndClose(jsonCommand, account);
if (!changes.isEmpty()) {
this.shareAccountRepository.saveAndFlush(account);
final String noteText = jsonCommand.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.shareNote(account, noteText);
changes.put("note", noteText);
this.noteRepository.save(note);
}
ShareAccountTransaction transaction = (ShareAccountTransaction) changes
.get(ShareAccountApiConstants.requestedshares_paramname);
transaction = account.getShareAccountTransaction(transaction);
// Account status/closedDate are applied regardless of whether there was anything left to
// redeem — save and note-capture must not be gated on a transaction actually existing.
this.shareAccountRepository.saveAndFlush(account);
final String noteText = jsonCommand.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.shareNote(account, noteText);
this.noteRepository.save(note);
}
// Only closures that actually redeemed remaining shares carry a transaction to post GL
// entries for — an account already at zero shares has none, and posting a zero-value
// transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL.
final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname);
if (transactionChange instanceof ShareAccountTransaction) {
ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange);
Set<ShareAccountTransaction> transactions = new HashSet<>();
transactions.add(transaction);
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
changes.clear();
changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId());
}
return new CommandProcessingResultBuilder() //
.withCommandId(jsonCommand.commandId()) //
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -688,6 +688,80 @@ fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
# ── Customer notification gateways (KYC exception outreach) ──────────────────
# Used by the "Log Customer Contact" action on the KYC Exceptions screen: choosing SMS, Email or
# WhatsApp transmits a notice to the customer and records the delivery outcome on the contact log.
#
# Email needs no configuration here — it uses the spring.mail.* SMTP settings above.
#
# SMS and WhatsApp are vendor-agnostic: the request shape is described by properties so switching
# gateway is a config change. `body-template` supports {{destination}}, {{message}}, {{senderId}}
# and {{templateId}}; values are escaped for the configured content-type at send time.
# Both are DISABLED by default — a channel that is off records the attempt and reports
# "not sent, no gateway configured" rather than silently claiming success.
#
# NOTE: this is NOT the LincPay eKYC endpoint used for login OTP above. That endpoint mints an OTP
# of its own and cannot carry an arbitrary message; point these at a real send-message API.
#
# Example (MSG91 transactional SMS):
# fineract.notification.sms.base-url=https://control.msg91.com
# fineract.notification.sms.path=/api/v5/flow/
# fineract.notification.sms.auth-header-name=authkey
# fineract.notification.sms.auth-header-value=<your authkey>
# fineract.notification.sms.body-template={"template_id":"{{templateId}}","short_url":"0",\
# "recipients":[{"mobiles":"{{destination}}","MESSAGE":"{{message}}"}]}
# fineract.notification.sms.reference-json-path=data.messageId
# ┌──────────────────────────────────────────────────────────────────────────┐
# │ LOCAL TEST GATEWAY — NOT A REAL SMS PROVIDER. │
# │ │
# │ These defaults point at scratchpad/mock-sms-gateway.js on localhost so │
# │ the outreach flow can be demonstrated end to end. NOTHING REACHES A │
# │ PHONE. Every contact-log row records provider=MOCK_SMS_GATEWAY_TEST_ONLY │
# │ so a mock send can never be mistaken for a real delivery in the audit │
# │ trail. │
# │ │
# │ BEFORE PRODUCTION: set FINERACT_NOTIFICATION_SMS_* to the real gateway, │
# │ or set FINERACT_NOTIFICATION_SMS_ENABLED=false. Do not ship the mock. │
# └──────────────────────────────────────────────────────────────────────────┘
fineract.notification.sms.enabled=${FINERACT_NOTIFICATION_SMS_ENABLED:true}
fineract.notification.sms.provider=${FINERACT_NOTIFICATION_SMS_PROVIDER:MOCK_SMS_GATEWAY_TEST_ONLY}
fineract.notification.sms.base-url=${FINERACT_NOTIFICATION_SMS_BASE_URL:http://127.0.0.1:9797}
fineract.notification.sms.path=${FINERACT_NOTIFICATION_SMS_PATH:/send}
fineract.notification.sms.auth-header-name=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_NAME:Authorization}
fineract.notification.sms.auth-header-value=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_VALUE:mock-test-key}
fineract.notification.sms.content-type=${FINERACT_NOTIFICATION_SMS_CONTENT_TYPE:application/json}
fineract.notification.sms.body-template=${FINERACT_NOTIFICATION_SMS_BODY_TEMPLATE:{"mobileNumber":"{{destination}}","message":"{{message}}","senderId":"{{senderId}}","templateId":"{{templateId}}"\}}
# DLT-registered sender ID and template ID — TRAI requires both for transactional SMS in India.
fineract.notification.sms.sender-id=${FINERACT_NOTIFICATION_SMS_SENDER_ID:}
fineract.notification.sms.template-id=${FINERACT_NOTIFICATION_SMS_TEMPLATE_ID:}
fineract.notification.sms.reference-json-path=${FINERACT_NOTIFICATION_SMS_REFERENCE_JSON_PATH:data.messageId}
# Example (Meta WhatsApp Cloud API):
# fineract.notification.whatsapp.base-url=https://graph.facebook.com
# fineract.notification.whatsapp.path=/v21.0/<phone-number-id>/messages
# fineract.notification.whatsapp.auth-header-value=Bearer <access token>
# fineract.notification.whatsapp.body-template={"messaging_product":"whatsapp",\
# "to":"{{destination}}","type":"text","text":{"body":"{{message}}"}}
# fineract.notification.whatsapp.reference-json-path=messages.0.id
# Business-initiated messages outside the 24h service window must use an approved template, so a
# KYC notice will normally need the template-shaped body rather than the plain "text" one above.
# Also pointed at the LOCAL TEST GATEWAY above — see that banner. Nothing reaches WhatsApp.
fineract.notification.whatsapp.enabled=${FINERACT_NOTIFICATION_WHATSAPP_ENABLED:true}
fineract.notification.whatsapp.provider=${FINERACT_NOTIFICATION_WHATSAPP_PROVIDER:MOCK_WHATSAPP_GATEWAY_TEST_ONLY}
fineract.notification.whatsapp.base-url=${FINERACT_NOTIFICATION_WHATSAPP_BASE_URL:http://127.0.0.1:9797}
fineract.notification.whatsapp.path=${FINERACT_NOTIFICATION_WHATSAPP_PATH:/send}
fineract.notification.whatsapp.auth-header-name=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_NAME:Authorization}
fineract.notification.whatsapp.auth-header-value=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_VALUE:mock-test-key}
fineract.notification.whatsapp.content-type=${FINERACT_NOTIFICATION_WHATSAPP_CONTENT_TYPE:application/json}
fineract.notification.whatsapp.body-template=${FINERACT_NOTIFICATION_WHATSAPP_BODY_TEMPLATE:{"to":"{{destination}}","message":"{{message}}"\}}
fineract.notification.whatsapp.sender-id=${FINERACT_NOTIFICATION_WHATSAPP_SENDER_ID:}
fineract.notification.whatsapp.template-id=${FINERACT_NOTIFICATION_WHATSAPP_TEMPLATE_ID:}
fineract.notification.whatsapp.reference-json-path=${FINERACT_NOTIFICATION_WHATSAPP_REFERENCE_JSON_PATH:data.messageId}
fineract.notification.whatsapp.country-code=${FINERACT_NOTIFICATION_WHATSAPP_COUNTRY_CODE:91}
# Days the customer is given to complete KYC, quoted in the notice body.
fineract.notification.kyc-contact.response-days=${FINERACT_NOTIFICATION_KYC_RESPONSE_DAYS:15}
# Frontend base URL — used for email links (voting, etc.)
cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in}
@@ -491,13 +491,28 @@
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0438_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0439_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
<include file="parts/0440_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0444_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0445_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0446_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
<include file="parts/0438_share_account_withdrawal_governance.xml" relativeToChangelogFile="true"/>
<include file="parts/0439_create_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0440_seed_share_redemption_payment_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0444_add_kyc_exception_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0445_create_kyc_contact_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0446_create_clerk_share_closure_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0447_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0448_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
<include file="parts/0449_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
<include file="parts/0450_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
<include file="parts/0451_seed_branch_offices.xml" relativeToChangelogFile="true"/>
<include file="parts/0452_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0453_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0454_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
<include file="parts/0456_add_freeze_date_and_amount_to_client_kyc.xml" relativeToChangelogFile="true"/>
<include file="parts/0457_add_unfreeze_date_to_client_kyc.xml" relativeToChangelogFile="true"/>
<include file="parts/0458_add_no_debit_date_to_client_kyc.xml" relativeToChangelogFile="true"/>
<include file="parts/0459_seed_aml_alerts_awaiting_review.xml" relativeToChangelogFile="true"/>
<include file="parts/0460_create_aml_recurring_review.xml" relativeToChangelogFile="true"/>
<include file="parts/0461_add_pan_declaration_to_client_kyc.xml" relativeToChangelogFile="true"/>
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -23,24 +23,32 @@
<!-- Dedicated liability GL account for the Sponsor DD Payable balance owed to SBI, as a child
of the existing "OTHER LIABILITY" account (gl_code 9), matching the pattern already used
by "INTEREST PAYABLE" (gl_code 9.2). -->
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account">
by "INTEREST PAYABLE" (gl_code 9.2).
parent_id is resolved by gl_code (not hardcoded) — the "OTHER LIABILITY" row's numeric id
is an auto-increment value that depends on each tenant's own insert history, so a
hardcoded id (previously 22) only happened to match the `default` tenant and broke FK
inserts on any tenant whose chart of accounts was seeded differently (e.g. `saga`), which
aborted that tenant's whole Liquibase run — and with it, the entire application startup.
runOnChange lets this corrected version safely re-validate on tenants where the old,
already-recorded version succeeded: the "gl_code 9.3 doesn't exist yet" precondition below
is already false there, so it's skipped rather than re-inserted. -->
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account" runOnChange="true">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
</not>
<and>
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
</not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9'</sqlCheck>
</and>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="Sponsor DD Payable - SBI"/>
<column name="gl_code" value="9.3"/>
<column name="parent_id" valueNumeric="22"/>
<column name="hierarchy" value="."/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="description" value="Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing."/>
</insert>
<sql>
INSERT INTO acc_gl_account (name, gl_code, parent_id, hierarchy, account_usage, classification_enum, disabled, manual_journal_entries_allowed, description)
SELECT 'Sponsor DD Payable - SBI', '9.3', parent.id, '.', 1, 2, FALSE, TRUE,
'Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing.'
FROM acc_gl_account parent
WHERE parent.gl_code = '9';
</sql>
</changeSet>
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
@@ -32,6 +32,13 @@
Threshold numbers marked "illustrative default" mirror the bank's own Rules Parameter
screens where a configured value was available (Rules 1, 6, 10, 11, 12, 13); others are
reasonable starting points meant to be tuned via the AML rule administration UI.
order_position is only unique WITHIN 0430's own 221-row insert - it collides with the
older, differently-numbered seed rows from 0428_add_client_risk_rules.xml (e.g. both
insert a row at order_position 10 and 30), which made every subquery below ambiguous
("more than one row returned") and aborted the whole tenant migration. aml_check_status
is populated only on 0430's rows (0428's predate that column and are NULL there), so
pairing it with order_position uniquely targets the intended 0430 row.
-->
<changeSet author="fineract" id="0434-1">
<preConditions onFail="MARK_RAN">
@@ -42,19 +49,19 @@
VALUES
('CTR_THRESHOLD_10L', 'Mandatory CTR reporting threshold - single cash transaction of INR 10,00,000 or more', 'ALL', 1000000, 'THRESHOLD', null, null, true),
('STRUCTURED_CTR_10L_MONTHLY', 'Connected cash transactions summing to INR 10,00,000 or more within a calendar month (CTR aggregation rule)', 'ALL', 1000000, 'STRUCTURING', 30, null, true),
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 LIMIT 1), true),
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 LIMIT 1), true),
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 LIMIT 1), true),
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 LIMIT 1), true),
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 LIMIT 1), true),
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 LIMIT 1), true),
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 LIMIT 1), true),
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 LIMIT 1), true),
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 LIMIT 1), true),
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 LIMIT 1), true),
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 LIMIT 1), true),
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 LIMIT 1), true),
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 LIMIT 1), true);
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 AND aml_check_status IS NOT NULL), true),
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 AND aml_check_status IS NOT NULL), true),
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 AND aml_check_status IS NOT NULL), true),
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 AND aml_check_status IS NOT NULL), true),
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 AND aml_check_status IS NOT NULL), true),
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 AND aml_check_status IS NOT NULL), true),
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 AND aml_check_status IS NOT NULL), true),
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 AND aml_check_status IS NOT NULL), true),
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 AND aml_check_status IS NOT NULL), true),
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 AND aml_check_status IS NOT NULL), true),
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 AND aml_check_status IS NOT NULL), true),
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 AND aml_check_status IS NOT NULL), true),
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 AND aml_check_status IS NOT NULL), true);
</sql>
</changeSet>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!--
Share withdrawal governance: a share account may optionally be linked to a loan (held
as margin/security) so a redeem-shares request can be checked against that loan's
outstanding balance; a redeem transaction records the Board Resolution Number and Date
of Meeting that authorised it.
-->
<changeSet author="fineract" id="0438-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_share_account" columnName="linked_loan_id"/></not>
</preConditions>
<addColumn tableName="m_share_account">
<column name="linked_loan_id" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0438-2">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="FK_m_share_account_linked_loan_id"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_share_account" baseColumnNames="linked_loan_id"
referencedTableName="m_loan" referencedColumnNames="id"
constraintName="FK_m_share_account_linked_loan_id" />
</changeSet>
<changeSet author="fineract" id="0438-3">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_share_account_transactions" columnName="resolution_number"/></not>
</preConditions>
<addColumn tableName="m_share_account_transactions">
<column name="resolution_number" type="VARCHAR(100)"/>
<column name="meeting_date" type="DATE"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Maker-checker for Share Account withdrawal (redeem shares) requests, submitted only from
the Clerk Dashboard — mirrors m_clerk_withdrawal_request (cash withdrawal), but records the
Board Resolution Number / Date of Meeting a share withdrawal is governed by, and links to a
Share Account instead of a Savings/Current account.
-->
<changeSet id="0439_create_clerk_share_withdrawal_request" author="CBS">
<createTable tableName="m_clerk_share_withdrawal_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="account_no" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="client_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(255)"/>
<column name="requested_shares" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="requested_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="resolution_number" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="meeting_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="description" type="TEXT"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="clerk_id" type="BIGINT"/>
<column name="clerk_name" type="VARCHAR(255)"/>
<column name="clerk_username" type="VARCHAR(100)"/>
<column name="manager_id" type="BIGINT"/>
<column name="manager_name" type="VARCHAR(255)"/>
<column name="manager_username" type="VARCHAR(100)"/>
<column name="rejection_reason" type="TEXT"/>
<column name="share_transaction_id" type="VARCHAR(100)"/>
<column name="activity_log" type="TEXT"/>
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="actioned_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_clerk_id">
<column name="clerk_id"/>
</createIndex>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_submitted_at">
<column name="submitted_at"/>
</createIndex>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_share_account_id">
<column name="share_account_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Real-world UCB practice: a share redemption's value is never handed over as cash from the
Share department — it is a book-transfer credited to the member's linked savings account
(Dr Share Capital, Cr Savings Control). This is a deliberate, manual SECOND entry the clerk
makes on the Transaction Entry screen (Deposit Slip on the linked savings account) after the
Share Withdrawal Request is approved and shares are redeemed — the redemption itself never
triggers this automatically. This Payment Type is the fund-source channel that drives correct
GL routing for that manual deposit; an accountant maps it to the Share Capital GL account via
the existing "Map Payment Types to Specific Funds" screen (we intentionally do NOT hardcode a
GL account id here — see 0345's fix for why a tenant-specific GL id baked into a migration
breaks on any tenant seeded differently). runOnChange lets this file's wording/flag tweaks
safely re-validate on any environment where it already ran — the "not already seeded"
precondition below is already false there, so it's skipped rather than re-inserted. -->
<changeSet author="fineract" id="0440-1-seed-share-redemption-payment-type" runOnChange="true">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from m_payment_type where code_name = 'SHARE_REDEMPTION_TRANSFER'</sqlCheck>
</preConditions>
<insert tableName="m_payment_type">
<column name="value" value="Share Redemption Transfer"/>
<column name="description" value="Manual book-transfer settlement of a share redemption to the member's linked savings account"/>
<column name="is_cash_payment" valueBoolean="false"/>
<column name="order_position" valueNumeric="0"/>
<column name="code_name" value="SHARE_REDEMPTION_TRANSFER"/>
<column name="is_system_defined" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The clerk picks which Payment Type the settlement deposit (credit to the linked savings
account) should use — this is captured at submit time, persisted here, validated (must not
be a cash payment type — settlement is always a book-transfer), and then actually used at
approval time instead of a hardcoded payment type lookup. Each column below is its own
changeset with its own existence guard — an earlier, now-superseded revision of this file
already created settlement_amount/settlement_savings_transaction_id on some environments
(including local dev), so a single combined addColumn would fail wherever those two already
exist but settlement_payment_type_id does not. -->
<changeSet author="fineract" id="0441-1-add-settlement-payment-type-id">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_payment_type_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_payment_type_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_clerk_share_wd_settlement_pt" references="m_payment_type(id)"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0441-2-add-settlement-amount">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_amount"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_amount" type="DECIMAL(19,6)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0441-3-add-settlement-savings-transaction-id">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_savings_transaction_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_savings_transaction_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Captures a full snapshot (as submitted JSON) of the CR/settlement row the clerk filled in on
the "Enter Bank Transfer" screen — account no., ECS a/c no., amount, cheque no./date, token
no., description, bank name, routing code — for audit purposes, alongside the columns that
already exist for the DR (Share) row's own fields (resolution_number, meeting_date, etc). -->
<changeSet author="fineract" id="0442-1-add-settlement-row-snapshot">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_row_snapshot"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_row_snapshot" type="TEXT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0444-kyc-is-exception" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="is_exception"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="is_exception" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0444-kyc-exception-reason" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="exception_reason"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="exception_reason" type="VARCHAR(200)"/>
</addColumn>
</changeSet>
<changeSet id="0444-kyc-exception-resolved" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="exception_resolved"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="exception_resolved" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0444-kyc-exception-resolved-on" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="exception_resolved_on"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="exception_resolved_on" type="DATE"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Audit trail of every attempt a KYC officer makes to reach a customer about an open KYC
exception. Two kinds of row land here and they must stay distinguishable:
* OFFLINE channels (PHONE_CALL / BRANCH_VISIT / FIELD_VISIT) — the officer is recording
something that already happened away from the system. delivery_status is NOT_APPLICABLE.
* SENDING channels (SMS / EMAIL / WHATSAPP) — the platform actually transmits a notice.
delivery_status records what the gateway said, so a failed send can never be mistaken
for a delivered one during an RBI inspection.
-->
<changeSet id="0445-create-client-kyc-contact-log" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_kyc_contact_log"/></not>
</preConditions>
<createTable tableName="m_client_kyc_contact_log">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="kyc_id" type="BIGINT"/>
<!-- PHONE_CALL | SMS | EMAIL | WHATSAPP | BRANCH_VISIT | FIELD_VISIT -->
<column name="channel" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<!-- Free-text summary the officer typed. Mandatory: this is the compliance narrative. -->
<column name="notes" type="VARCHAR(1000)">
<constraints nullable="false"/>
</column>
<!-- Officer as selected in the dialog, plus the authenticated user who really saved it. -->
<column name="officer_name" type="VARCHAR(200)"/>
<column name="logged_by_user_id" type="BIGINT"/>
<!-- Snapshot of the KYC exception reason at the time of contact. -->
<column name="exception_reason" type="VARCHAR(200)"/>
<!-- NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED -->
<column name="delivery_status" type="VARCHAR(30)" defaultValue="NOT_APPLICABLE">
<constraints nullable="false"/>
</column>
<!-- Masked destination only (+91-XXXXX-1234 / a****@x.com). Never store it unmasked. -->
<column name="destination_masked" type="VARCHAR(120)"/>
<column name="provider" type="VARCHAR(40)"/>
<column name="provider_reference" type="VARCHAR(120)"/>
<column name="failure_reason" type="VARCHAR(500)"/>
<!-- Exact body transmitted, so the bank can prove what the customer was told. -->
<column name="message_body" type="VARCHAR(2000)"/>
<column name="contacted_at" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="created_at" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_client_kyc_contact_log"
baseColumnNames="client_id"
constraintName="fk_kyc_contact_log_client"
referencedTableName="m_client"
referencedColumnNames="id"/>
<createIndex tableName="m_client_kyc_contact_log" indexName="idx_kyc_contact_log_client">
<column name="client_id"/>
</createIndex>
<createIndex tableName="m_client_kyc_contact_log" indexName="idx_kyc_contact_log_contacted_at">
<column name="contacted_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Maker-checker workflow for closing a Share Account, mirroring m_clerk_share_withdrawal_request
(see ClerkShareWithdrawalRequestApiResource). A clerk submits a closure request only once all
shares have already been withdrawn (zero shares held); a Bank Manager approves it, which
server-side executes the real Fineract "close" command against the share account, or rejects it. -->
<changeSet author="fineract" id="0446-1-create-clerk-share-closure-request">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_clerk_share_closure_request"/>
</not>
</preConditions>
<createTable tableName="m_clerk_share_closure_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="account_no" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="true"/>
</column>
<column name="client_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
<column name="closed_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="note" type="TEXT">
<constraints nullable="true"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="clerk_id" type="BIGINT">
<constraints nullable="true"/>
</column>
<column name="clerk_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
<column name="clerk_username" type="VARCHAR(100)">
<constraints nullable="true"/>
</column>
<column name="manager_id" type="BIGINT">
<constraints nullable="true"/>
</column>
<column name="manager_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
<column name="manager_username" type="VARCHAR(100)">
<constraints nullable="true"/>
</column>
<column name="rejection_reason" type="TEXT">
<constraints nullable="true"/>
</column>
<column name="activity_log" type="TEXT">
<constraints nullable="true"/>
</column>
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="actioned_at" type="TIMESTAMP">
<constraints nullable="true"/>
</column>
</createTable>
<createIndex indexName="idx_cscr_share_account_id" tableName="m_clerk_share_closure_request">
<column name="share_account_id"/>
</createIndex>
<createIndex indexName="idx_cscr_status" tableName="m_clerk_share_closure_request">
<column name="status"/>
</createIndex>
<createIndex indexName="idx_cscr_clerk_id" tableName="m_clerk_share_closure_request">
<column name="clerk_id"/>
</createIndex>
<createIndex indexName="idx_cscr_submitted_at" tableName="m_clerk_share_closure_request">
<column name="submitted_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -25,7 +25,7 @@
<!-- Optional risk-category-tiered thresholds. Null on both columns means the rule is not
tiered and threshold_amount alone is used (unchanged behaviour for every pre-existing rule). -->
<changeSet author="fineract" id="0438-1">
<changeSet author="fineract" id="0447-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_medium_risk"/></not>
</preConditions>
@@ -36,7 +36,7 @@
</addColumn>
</changeSet>
<changeSet author="fineract" id="0438-2">
<changeSet author="fineract" id="0447-2">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_high_risk"/></not>
</preConditions>
@@ -49,7 +49,7 @@
<!-- RTGS monitoring: Medium-risk clients are flagged at INR 5,00,000, High-risk at INR 20,00,000.
threshold_amount (2,000,000) remains the fallback for Low-risk/unclassified clients. -->
<changeSet author="fineract" id="0438-3">
<changeSet author="fineract" id="0447-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'RTGS_HIGH_VALUE' AND threshold_amount_medium_risk IS NOT NULL</sqlCheck>
</preConditions>
@@ -69,7 +69,7 @@
an account balance level). Seeded here for catalog/display parity with m_client_risk_rule,
with is_active = false so it is never picked up by AmlRuleEvaluationServiceImpl until a
balance-based rule type is built. -->
<changeSet author="fineract" id="0438-4">
<changeSet author="fineract" id="0447-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY'</sqlCheck>
</preConditions>
@@ -23,7 +23,7 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<changeSet author="fineract" id="0439-1">
<changeSet author="fineract" id="0448-1">
<validCheckSum>*</validCheckSum>
<sql>
-- 1. High Risk Occupations (Real Estate, Importer/Exporter, Commission Agent, Financial Advisor)
@@ -23,7 +23,7 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<changeSet author="fineract" id="0440-1">
<changeSet author="fineract" id="0449-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client WHERE external_id = 'CLIENT_RAJESH'</sqlCheck>
</preConditions>
@@ -64,7 +64,7 @@
</sql>
</changeSet>
<changeSet author="fineract" id="0440-2">
<changeSet author="fineract" id="0449-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_aml_alert a
@@ -33,7 +33,7 @@
a display column would change which transactions raise alerts. These new columns
are read-only documentation of the band a rule covers and never affect matching.
-->
<changeSet author="fineract" id="0441-1">
<changeSet author="fineract" id="0450-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_aml_transaction_rule" columnName="min_limit_amount"/>
@@ -50,7 +50,7 @@
is the amount beyond which the exposure is treated as materially more serious and
escalated. A NULL maximum means the rule is genuinely open-ended.
-->
<changeSet author="fineract" id="0441-2">
<changeSet author="fineract" id="0450-2">
<sql>
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
WHERE rule_code = 'CTR_THRESHOLD_10L';
@@ -93,7 +93,7 @@
UI cannot distinguish from "not captured". Normalise the zeros to NULL so an absent
ceiling reads as genuinely open-ended, and give the medium bands their missing floors.
-->
<changeSet author="fineract" id="0441-3">
<changeSet author="fineract" id="0450-3">
<sql>
UPDATE m_client_risk_rule SET max_monthly_income = NULL WHERE max_monthly_income = 0;
UPDATE m_client_risk_rule SET max_annual_income = NULL WHERE max_annual_income = 0;
@@ -31,7 +31,7 @@
RBI return screens, which is what lets a logged-in user's office be resolved to a
branch without matching on display names.
-->
<changeSet author="fineract" id="0442-1">
<changeSet author="fineract" id="0451-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006')</sqlCheck>
</preConditions>
@@ -47,7 +47,7 @@
</changeSet>
<!-- Head Office carries the branch code the GL ledger uses for it. -->
<changeSet author="fineract" id="0442-2">
<changeSet author="fineract" id="0451-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id = 'HO'</sqlCheck>
</preConditions>
@@ -32,7 +32,7 @@
threshold would be evaluated against every live transaction. Inactive keeps it out of the
engine entirely; the referral service loads it by rule_code instead.
-->
<changeSet author="fineract" id="0443-1">
<changeSet author="fineract" id="0452-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL'</sqlCheck>
</preConditions>
@@ -34,7 +34,12 @@
the public pixel endpoint (GET /api/*/clients/edit/pixel/{token}) so the endpoint
never exposes integer IDs. Unique constraint prevents accidental re-use. -->
<changeSet author="CBS" id="0444-1-add-email-tracking-token">
<changeSet author="CBS" id="0453-1-add-email-tracking-token">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_tracking_token"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_tracking_token" type="VARCHAR(36)">
<constraints nullable="true" unique="true"
@@ -43,7 +48,12 @@
</addColumn>
</changeSet>
<changeSet author="CBS" id="0444-2-add-email-sent">
<changeSet author="CBS" id="0453-2-add-email-sent">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_sent"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_sent" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
@@ -51,7 +61,12 @@
</addColumn>
</changeSet>
<changeSet author="CBS" id="0444-3-add-email-sent-at">
<changeSet author="CBS" id="0453-3-add-email-sent-at">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_sent_at"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_sent_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="true"/>
@@ -59,7 +74,12 @@
</addColumn>
</changeSet>
<changeSet author="CBS" id="0444-4-add-email-opened-at">
<changeSet author="CBS" id="0453-4-add-email-opened-at">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_opened_at"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_opened_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="true"/>
@@ -67,7 +87,12 @@
</addColumn>
</changeSet>
<changeSet author="CBS" id="0444-5-add-email-subject">
<changeSet author="CBS" id="0453-5-add-email-subject">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_subject"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_subject" type="VARCHAR(300)">
<constraints nullable="true"/>
@@ -75,7 +100,12 @@
</addColumn>
</changeSet>
<changeSet author="CBS" id="0444-6-add-email-body">
<changeSet author="CBS" id="0453-6-add-email-body">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_body"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_body" type="TEXT">
<constraints nullable="true"/>
@@ -3,6 +3,11 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="antigravity" id="add_email_from_to_client_pending_edit">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_pending_edit" columnName="email_from"/>
</not>
</preConditions>
<addColumn tableName="m_client_pending_edit">
<column name="email_from" type="VARCHAR(255)"/>
</addColumn>
@@ -3,6 +3,11 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="1" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_family_member_children"/>
</not>
</preConditions>
<createTable tableName="m_family_member_children">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet id="0456-kyc-freeze-date" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="freeze_date"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="freeze_date" type="DATE"/>
</addColumn>
</changeSet>
<changeSet id="0456-kyc-freeze-amount" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="freeze_amount"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="freeze_amount" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>

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