Compare commits

...
18 Commits
Author SHA1 Message Date
nitesh 94b0afff38 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
2026-08-11 12:53:20 +05:30
niteshandClaude Sonnet 5 9dd4f8bedb feat(shares): real-profit driven dividends with GL-only liability model and member claim workflow
Reworks the share dividend lifecycle end to end:
- Dividend amount now auto-computed from real GL profit x officer-entered percentage
  instead of manual entry; adds a Dividend Paid report over the existing Dividend
  Payable ledger.
- Declaration posts a real GL entry (Dr Reserve for Dividend / Cr Dividend Payable);
  approval no longer credits any savings account up front -- every member's
  entitlement stays a pure "Dividend Payable" liability until claimed, resolved
  synchronously at approval time (no background job or per-member failure mode).
- New maker-checker Dividend Claim Request workflow: a clerk records how a member
  wants their unclaimed dividend split (overdue loan / savings credit / cash), a
  manager approves, which executes native repayment/deposit commands and replenishes
  whichever GL account they actually debited (not a hardcoded assumption) from the
  Dividend Payable liability.
- Unclaimed dividends carry a 3-year window (m_share_dividend_lock) after which a
  daily job forfeits them straight to profit; a Dividend Dashboard consolidates
  summary stats, every declaration's status, full lock history, and proactive
  loan-application for members with overdue EMIs.
- Fixes a native negative-share-days bug in ShareProductDividendAssembler and adds
  overlap-period validation so a product can't have two declarations covering the
  same days.
- Removes the now-unreachable failed/pending-payout tracking that the old
  per-member job-based model needed.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:53:27 +05:30
138 changed files with 7670 additions and 4591 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ gitVersioning.apply {
}
ext['groovy.version'] = '4.0.17'
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
// Apply static weaving configuration to all subprojects
subprojects { subproject ->
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -54,6 +54,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
+1 -1
View File
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
// Configure Java compilation
tasks.named('compileJava') {
outputs.cacheIf { true }
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
}
@@ -53,6 +53,7 @@
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
@@ -436,7 +436,9 @@ public final class AccountingConstants {
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
private final Integer value;
private final String code;
@@ -38,6 +38,7 @@ public enum JobName {
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
@@ -36,6 +36,9 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
long countByClientId(Long clientId);
/** Alerts for one customer inside the recurring-behaviour observation window. */
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
/**
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
*
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
*/
@Entity
@Table(name = "m_aml_recurring_review")
public class AmlRecurringReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "alert_count_at_review", nullable = false)
private Integer alertCountAtReview;
@Column(name = "outcome", nullable = false, length = 30)
private String outcome;
@Column(name = "notes", nullable = false, length = 1000)
private String notes;
@ManyToOne(optional = false)
@JoinColumn(name = "reviewed_by", nullable = false)
private AppUser reviewedBy;
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
final AmlRecurringReview review = new AmlRecurringReview();
review.client = client;
review.alertCountAtReview = alertCountAtReview;
review.outcome = outcome.name();
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
return review;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
public Integer getAlertCountAtReview() {
return alertCountAtReview;
}
public AmlRecurringReviewOutcome getOutcome() {
return AmlRecurringReviewOutcome.valueOf(outcome);
}
public String getNotes() {
return notes;
}
public AppUser getReviewedBy() {
return reviewedBy;
}
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** What a reviewer concluded about a customer's repeated AML activity. */
public enum AmlRecurringReviewOutcome {
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
CLEARED,
/** Not conclusive; keep watching this customer more closely. */
MONITOR,
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
ESCALATED
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
/** Newest first, so the caller can take the head as the latest review for a customer. */
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
}
@@ -86,6 +86,29 @@ public class AmlTransactionRule {
protected AmlTransactionRule() {}
/**
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
* the two rows are always created together and the rule code is derived from it.
*/
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
final AmlTransactionRule rule = new AmlTransactionRule();
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
rule.description = description;
rule.appliesTo = appliesTo.name();
rule.ruleType = ruleType.name();
rule.windowDays = windowDays;
rule.thresholdAmount = thresholdAmount;
rule.minLimitAmount = minLimitAmount;
rule.maxLimitAmount = maxLimitAmount;
rule.active = true;
return rule;
}
public Long getId() {
return id;
}
@@ -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>
@@ -0,0 +1,131 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
* is, by construction, a real dividend payment.
*/
@Path("/v1/accounting/dividend-paid-report")
@Component
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
@RequiredArgsConstructor
public class DividendPaidReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final JsonObject result = new JsonObject();
result.addProperty("fromDate", fromDate);
result.addProperty("toDate", toDate);
if (payableDividendsAccountId == null) {
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
result.addProperty("totalPaid", BigDecimal.ZERO);
result.addProperty("payoutCount", 0);
result.add("entries", new JsonArray());
result.addProperty("glAccountConfigured", false);
return gson.toJson(result);
}
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
SELECT entry_date, amount, transaction_id
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
ORDER BY entry_date DESC
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
result.addProperty("glAccountConfigured", true);
final JsonArray entries = new JsonArray();
for (final Map<String, Object> row : entryRows) {
final JsonObject entry = new JsonObject();
entry.addProperty("entryDate", row.get("entry_date").toString());
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("transactionId", (String) row.get("transaction_id"));
entries.add(entry);
}
result.add("entries", entries);
return gson.toJson(result);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,98 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Real Profit &amp; Loss (Net Profit) for a date range, computed live from posted General Ledger
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
*
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
* and decreases with a CREDIT. Reversed entries are excluded.
*/
@Path("/v1/accounting/profitandloss")
@Component
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
@RequiredArgsConstructor
public class ProfitAndLossApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
"netProfit", netProfit));
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -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");
}
}
@@ -0,0 +1,513 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker workflow for a member to claim an unclaimed dividend (see
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
* that was never credited into any savings account). A clerk (maker) records exactly how the
* member wants it split when they visit the branch: straight into an overdue loan, credited to
* their linked savings account, or handed over as cash -- any combination, any leftover amount
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
*
* <p>
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
* which debits whatever GL account is actually configured as that product's Fund Source -- not
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
* "Warehouse Receipt" account instead). Since the money never actually came from that account, an
* immediate manual journal entry (Dr Dividend Payable / Cr whichever account was actually debited
* -- read straight off the GL entry the native command just posted) replenishes it for that same
* amount, so the net effect is exactly "Dividend Payable decreases, loan/savings changes, that
* fund-source account is untouched". The cash leg has no account to post through, so it's a direct
* manual entry only (Dr Dividend Payable / Cr Cash at Main Vault) -- real cash leaves the vault
* into the member's hand.
*/
@Path("/v1/clerk/dividend-claim-requests")
@Component
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
@RequiredArgsConstructor
public class DividendClaimRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
"r.savings_apply_amount", "r.cash_apply_amount", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
"r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at" };
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
@GET
@Path("/overdue-loans/{clientId}")
@Produces(MediaType.APPLICATION_JSON)
public String overdueLoans(@PathParam("clientId") final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
row.addProperty("accountNo", (String) loan.get("account_no"));
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
result.add(row);
}
return gson.toJson(result);
}
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submit(final String body) {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root));
if (lockId == null) {
throw apiError(400, "lockId is required");
}
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT client_id, savings_account_id, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId)
.stream().findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final Object lockSavingsAccountId = lock.get("savings_account_id");
// The share account isn't required to have a linked savings account up front any more --
// if the member wants a savings credit and none is linked, the clerk supplies which
// account to credit right here.
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
: savingsAccountIdOverride;
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (resolvedSavingsAccountId == null) {
throw apiError(400,
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
}
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
resolvedSavingsAccountId);
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
}
}
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
}
if (total.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (loanId == null) {
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
}
final Map<String, Object> loan = jdbcTemplate
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
}
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
throw apiError(400, "Loan " + loanId + " is not Active");
}
}
final Long existingPending = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
if (existingPending != null && existingPending > 0) {
throw apiError(409, "A claim request for this dividend is already pending review.");
}
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_share_dividend_claim_request
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
cash_apply_amount, status, clerk_id, clerk_name, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
RETURNING id
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
private BigDecimal parseAmount(final String value) {
if (value == null || value.isBlank()) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(value.trim());
} catch (final NumberFormatException e) {
throw apiError(400, "'" + value + "' is not a valid amount");
}
}
// ── GET /v1/clerk/dividend-claim-requests ── Manager sees claim requests ────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + """
FROM m_share_dividend_claim_request r
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
JOIN m_share_dividend_lock l ON l.id = r.lock_id
WHERE r.status = ?
ORDER BY r.submitted_at ASC
""", status);
for (final Map<String, Object> row : rows) {
final Long clientId = ((Number) row.get("client_id")).longValue();
row.put("has_overdue_loan", hasOverdueLoan(clientId));
}
return gson.toJson(rows);
}
private boolean hasOverdueLoan(final Long clientId) {
final Long count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
""", Long.class, clientId);
return count != null && count > 0;
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/approve ── Manager executes the disbursement ──
@POST
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
}
final Long lockId = ((Number) record.get("lock_id")).longValue();
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
}
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(unclaimedAmount) > 0) {
throw apiError(409,
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
}
final Object claimSavingsAccountId = record.get("savings_account_id");
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final Long loanId = ((Number) record.get("loan_id")).longValue();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", loanApplyAmount);
payload.addProperty("note", note);
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
replenishFundSourceFromDividendPayable(id, currencyCode, loanApplyAmount, today, fundSourceAccountId);
}
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", savingsApplyAmount);
payload.addProperty("note", note);
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
final Long fundSourceAccountId = resolveDebitAccountForSavingsTransaction(depositResult.getResourceId());
replenishFundSourceFromDividendPayable(id, currencyCode, savingsApplyAmount, today, fundSourceAccountId);
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
}
final BigDecimal remainder = unclaimedAmount.subtract(total);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
} else {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
}
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), id);
final JsonObject response = new JsonObject();
response.addProperty("resourceId", id);
response.addProperty("status", "APPROVED");
response.addProperty("totalDisbursed", total);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
return gson.toJson(response);
}
/**
* Which GL account a loan repayment or savings deposit's cash/fund-source leg actually debits
* is a per-product (sometimes per-payment-type) configuration choice, not always literally
* "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its own
* "Warehouse Receipt" account instead. Rather than assume, these read the actual GL entry the
* native command just posted for this transaction and return whichever account it debited.
*/
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
return jdbcTemplate
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
.stream().findFirst().orElse(null);
}
private Long resolveDebitAccountForSavingsTransaction(final Long savingsTransactionId) {
return jdbcTemplate
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ?",
Long.class, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
.stream().findFirst().orElse(null);
}
/**
* The loan repayment / savings deposit just posted above assumed the money came out of
* whichever account is that product's actual Fund Source -- it didn't really. This puts that
* fund straight back, sourced from the Dividend Payable liability instead, so the net effect
* is exactly "liability shrinks, the loan/savings account changes, that fund-source account is
* unaffected". Skipped (loan/savings still updated) if the bank hasn't mapped the Dividend
* Payable financial activity, or if the command's own debit leg couldn't be found.
*/
private void replenishFundSourceFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate, final Long fundSourceAccountId) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
final String description = "Dividend claim #" + claimRequestId + " disbursed";
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
/**
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
* member's hand.
*/
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long cashAtVaultAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
final String description = "Dividend claim #" + claimRequestId + " disbursed";
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
@POST
@Path("/{id}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be rejected. Current status: " + record.get("status"));
}
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (reason == null || reason.isBlank()) {
throw apiError(400, "Rejection reason is required");
}
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, rejection_reason = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), reason, Timestamp.from(Instant.now()), id);
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject a dividend claim request.");
}
}
private Map<String, Object> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
status, clerk_id, clerk_name
FROM m_share_dividend_claim_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
throw new NotFoundException("Dividend claim request not found: " + id);
}
return rows.get(0);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,430 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.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;
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
* progress, full 3-year lock history, and members whose unclaimed dividend (see
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
* to be forfeited to profit after 3 years.
*/
@Path("/v1/clerk/dividend-dashboard")
@Component
@Tag(name = "Dividend Dashboard", description = "Bank Manager view of locked share dividends belonging to members with overdue loan EMIs, with an action to apply the dividend towards the loan.")
@RequiredArgsConstructor
public class DividendDashboardApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final int DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL = 100;
private static final int DIVIDEND_PAYOUT_STATUS_APPROVED = 300;
// ── GET /v1/clerk/dividend-dashboard/summary ─────────────────────────────────
@GET
@Path("/summary")
@Produces(MediaType.APPLICATION_JSON)
public String summary() {
final Map<String, Object> declarationStats = jdbcTemplate.queryForMap("""
SELECT COUNT(*) AS total_declarations,
COUNT(*) FILTER (WHERE status = %d) AS pending_approval_declarations,
COUNT(*) FILTER (WHERE status = %d) AS approved_declarations
FROM m_share_product_dividend_pay_out
""".formatted(DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL, DIVIDEND_PAYOUT_STATUS_APPROVED));
// Approval and disbursement now happen atomically (see
// ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable) -- there's no
// more "posted vs pending vs failed" spread to track at the detail level once approved.
final Map<String, Object> detailStats = jdbcTemplate.queryForMap("""
SELECT COUNT(DISTINCT sa.client_id) AS members_affected,
COUNT(sad.id) AS total_member_dividends,
COALESCE(SUM(sad.amount), 0) AS total_amount
FROM m_share_account_dividend_details sad
JOIN m_share_account sa ON sa.id = sad.account_id
""");
final Map<String, Object> lockStats = jdbcTemplate.queryForMap("""
SELECT COUNT(*) FILTER (WHERE status = 'UNCLAIMED') AS locked_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'UNCLAIMED'), 0) AS locked_amount,
COUNT(*) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL) AS applied_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL), 0) AS applied_amount,
COUNT(*) FILTER (WHERE status = 'FORFEITED') AS forfeited_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'FORFEITED'), 0) AS forfeited_amount,
COUNT(*) FILTER (WHERE status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED'), 0) AS claimed_amount
FROM m_share_dividend_lock
""");
final JsonObject result = new JsonObject();
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
result.addProperty("pendingApprovalDeclarations",
((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
result.addProperty("totalAmount", (BigDecimal) detailStats.get("total_amount"));
result.addProperty("lockedCount", ((Number) lockStats.get("locked_count")).longValue());
result.addProperty("lockedAmount", (BigDecimal) lockStats.get("locked_amount"));
result.addProperty("appliedToLoanCount", ((Number) lockStats.get("applied_count")).longValue());
result.addProperty("appliedToLoanAmount", (BigDecimal) lockStats.get("applied_amount"));
result.addProperty("forfeitedCount", ((Number) lockStats.get("forfeited_count")).longValue());
result.addProperty("forfeitedAmount", (BigDecimal) lockStats.get("forfeited_amount"));
result.addProperty("claimedCount", ((Number) lockStats.get("claimed_count")).longValue());
result.addProperty("claimedAmount", (BigDecimal) lockStats.get("claimed_amount"));
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
/**
* Every dividend ever declared (across every share product), so a manager tracking multiple
* dividends at once can see -- per declaration -- how many members it covers and how far its
* payout has actually progressed, not just whether it was approved.
*/
@GET
@Path("/declarations")
@Produces(MediaType.APPLICATION_JSON)
public String declarations() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT spd.id AS declaration_id, sp.name AS product_name,
spd.dividend_period_start_date, spd.dividend_period_end_date, spd.amount AS declared_amount,
spd.status AS declaration_status, spd.created_date, spd.lastmodified_date AS approved_on,
COUNT(sad.id) AS total_members,
COALESCE(SUM(sad.amount), 0) AS total_distributed_amount
FROM m_share_product_dividend_pay_out spd
JOIN m_share_product sp ON sp.id = spd.product_id
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = spd.id
GROUP BY spd.id, sp.name, spd.dividend_period_start_date, spd.dividend_period_end_date,
spd.amount, spd.status, spd.created_date, spd.lastmodified_date
ORDER BY spd.created_date DESC
""");
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final int declarationStatus = ((Number) row.get("declaration_status")).intValue();
final Timestamp approvedOn = (Timestamp) row.get("approved_on");
final boolean isApproved = declarationStatus == DIVIDEND_PAYOUT_STATUS_APPROVED;
final JsonObject entry = new JsonObject();
entry.addProperty("declarationId", ((Number) row.get("declaration_id")).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
entry.addProperty("periodStartDate", row.get("dividend_period_start_date").toString());
entry.addProperty("periodEndDate", row.get("dividend_period_end_date").toString());
entry.addProperty("declaredAmount", (BigDecimal) row.get("declared_amount"));
entry.addProperty("totalDistributedAmount", (BigDecimal) row.get("total_distributed_amount"));
entry.addProperty("createdDate", row.get("created_date").toString());
entry.addProperty("approvedOn", approvedOn == null ? null : approvedOn.toString());
entry.addProperty("totalMembers", ((Number) row.get("total_members")).longValue());
entry.addProperty("status", isApproved ? "APPROVED" : "PENDING_APPROVAL");
result.add(entry);
}
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
/**
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
* profit). Optional {@code status} query param filters to one.
*/
@GET
@Path("/locks")
@Produces(MediaType.APPLICATION_JSON)
public String locks(@QueryParam("status") final String status) {
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
+ "FROM m_share_dividend_lock l " //
+ "JOIN m_client c ON c.id = l.client_id " //
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
+ (filterByStatus ? "WHERE l.status = ? " : "") //
+ "ORDER BY l.locked_date DESC";
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
: jdbcTemplate.queryForList(sql);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
entry.addProperty("clientName", (String) row.get("client_name"));
final Object savingsAccountId = row.get("savings_account_id");
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("lockedDate", row.get("locked_date").toString());
entry.addProperty("releaseDueDate", row.get("release_due_date").toString());
entry.addProperty("status", (String) row.get("status"));
final Object appliedLoanId = row.get("applied_loan_id");
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
result.add(entry);
}
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard ─────────────────────────────────────────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
l.locked_date, l.release_due_date
FROM m_share_dividend_lock l
JOIN m_client c ON c.id = l.client_id
WHERE l.status = 'UNCLAIMED'
AND EXISTS (
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = l.client_id AND ln.loan_status_id = 300
AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
)
ORDER BY l.locked_date ASC
""");
final JsonArray result = new JsonArray();
for (final Map<String, Object> lock : locks) {
final Long clientId = ((Number) lock.get("client_id")).longValue();
final JsonObject row = new JsonObject();
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
row.addProperty("clientId", clientId);
row.addProperty("clientName", (String) lock.get("client_name"));
final Object savingsAccountId = lock.get("savings_account_id");
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
row.addProperty("lockedDate", lock.get("locked_date").toString());
row.addProperty("releaseDueDate", lock.get("release_due_date").toString());
row.add("overdueLoans", overdueLoansFor(clientId));
result.add(row);
}
return gson.toJson(result);
}
private JsonArray overdueLoansFor(final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
row.addProperty("accountNo", (String) loan.get("account_no"));
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
result.add(row);
}
return result;
}
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
/**
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
* and file a claim. Since the dividend was never credited into any savings account (it's a
* pure "Dividend Payable" GL liability until claimed -- see
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
* native and correct) and then replenishes the vault it assumed the cash came from, sourced
* instead from the Dividend Payable liability -- identical mechanic to a claim's loan leg.
*/
@POST
@Path("/{lockId}/apply-to-loan")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String applyToLoan(@PathParam("lockId") final Long lockId, final String body) {
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT client_id, currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final JsonElement root = fromJsonHelper.parse(body);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final String amountString = fromJsonHelper.extractStringNamed("amount", root);
if (loanId == null) {
throw apiError(400, "loanId is required");
}
BigDecimal applyAmount;
try {
applyAmount = amountString == null ? null : new BigDecimal(amountString.trim());
} catch (final NumberFormatException e) {
throw apiError(400, "amount must be a valid number");
}
if (applyAmount == null || applyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "amount must be greater than zero");
}
if (applyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "amount cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
final Map<String, Object> loan = jdbcTemplate.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId)
.stream().findFirst().orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", applyAmount);
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
replenishFundSourceFromDividendPayable(lockId, currencyCode, applyAmount, today, fundSourceAccountId);
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
} else {
jdbcTemplate.update("""
UPDATE m_share_dividend_lock
SET status = 'CLAIMED', applied_loan_id = ?, applied_transaction_id = ?, last_modified_date = now()
WHERE id = ?
""", loanId, repaymentResult.getResourceId(), lockId);
}
final JsonObject response = new JsonObject();
response.addProperty("lockId", lockId);
response.addProperty("appliedAmount", applyAmount);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
return gson.toJson(response);
}
/**
* Which GL account a loan repayment's cash/fund-source leg actually debits is a per-product
* (sometimes per-payment-type) configuration choice, not always literally "Cash at Main Vault"
* -- e.g. a Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account
* instead. Rather than assume, this reads the actual GL entry the native repayment command
* just posted for this transaction and returns whichever account it debited.
*/
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
return jdbcTemplate
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
.stream().findFirst().orElse(null);
}
/**
* The loan repayment just posted above assumed the money came out of whichever account is
* this loan product's actual Fund Source -- it didn't really. This puts that fund straight
* back, sourced from the Dividend Payable liability instead, so the net effect is exactly
* "liability shrinks, loan reduces, that fund-source account is unaffected". Skipped (loan
* still updated) if the bank hasn't mapped the Dividend Payable financial activity, or if the
* repayment's own debit leg couldn't be found.
*/
private void replenishFundSourceFromDividendPayable(final Long lockId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate, final Long fundSourceAccountId) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVAPPLYLOAN" + lockId + "-" + System.nanoTime();
final String description = "Unclaimed dividend lock #" + lockId + " applied to loan";
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,86 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every Share Product's dividend declaration that is still awaiting approval — Fineract only
* exposes dividends scoped to one product at a time
* ({@code GET /shareproduct/{productId}/dividend}), so a Bank Manager has no single place to see
* "everything currently waiting on me" across products. This aggregates all
* {@code m_share_product_dividend_pay_out} rows still INITIATED so they can be surfaced on the
* Approval Center (Checker Inbox) alongside the other maker-checker request types. Approval
* itself still goes through Fineract's native {@code approveDividend} command from the
* frontend — this resource only covers the missing cross-product listing.
*/
@Path("/v1/clerk/pending-dividends")
@Component
@Tag(name = "Pending Dividends", description = "Dividend declarations awaiting approval, across all Share Products.")
@RequiredArgsConstructor
public class PendingDividendsApiResource {
private static final int DIVIDEND_PAYOUT_STATUS_INITIATED = 100;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT p.id AS dividend_id, p.product_id, sp.name AS product_name, p.amount,
p.dividend_period_start_date, p.dividend_period_end_date, p.created_date,
(SELECT COUNT(*) FROM m_share_account_dividend_details d WHERE d.dividend_pay_out_id = p.id) AS member_count
FROM m_share_product_dividend_pay_out p
JOIN m_share_product sp ON sp.id = p.product_id
WHERE p.status = ?
ORDER BY p.created_date ASC
""", DIVIDEND_PAYOUT_STATUS_INITIATED);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("dividendId", ((Number) row.get("dividend_id")).longValue());
entry.addProperty("productId", ((Number) row.get("product_id")).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("dividendPeriodStartDate", row.get("dividend_period_start_date").toString());
entry.addProperty("dividendPeriodEndDate", row.get("dividend_period_end_date").toString());
entry.addProperty("declaredOn", row.get("created_date") == null ? null : row.get("created_date").toString());
entry.addProperty("memberCount", ((Number) row.get("member_count")).intValue());
result.add(entry);
}
return gson.toJson(result);
}
}
@@ -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;
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.shareaccounts.jobs.forfeitunclaimeddividends;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ForfeitUnclaimedDividendsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ShareAccountSchedularService shareAccountSchedularService;
@Bean
protected Step forfeitUnclaimedDividendsStep() {
return new StepBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository)
.tasklet(forfeitUnclaimedDividendsTasklet(), transactionManager).build();
}
@Bean
public Job forfeitUnclaimedDividendsJob() {
return new JobBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository).start(forfeitUnclaimedDividendsStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public ForfeitUnclaimedDividendsTasklet forfeitUnclaimedDividendsTasklet() {
return new ForfeitUnclaimedDividendsTasklet(jdbcTemplate, shareAccountSchedularService);
}
}
@@ -0,0 +1,67 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.shareaccounts.jobs.forfeitunclaimeddividends;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
* forfeited — the hold on the member's savings account is released and the amount is swept back
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
* the actual GL/balance mechanics.
*/
@Slf4j
@RequiredArgsConstructor
public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
private final ShareAccountSchedularService shareAccountSchedularService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final List<Throwable> exceptions = new ArrayList<>();
final List<Long> lockIds = this.jdbcTemplate.queryForList(
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
for (final Long lockId : lockIds) {
try {
this.shareAccountSchedularService.forfeitDividendLock(lockId);
} catch (final Exception e) {
log.error("Forfeit Unclaimed Dividends failed for share dividend lock id: {}", lockId, e);
exceptions.add(e);
}
}
if (!exceptions.isEmpty()) {
throw new JobExecutionException(exceptions);
}
return RepeatStatus.FINISHED;
}
}
@@ -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() {
@@ -22,4 +22,11 @@ public interface ShareAccountSchedularService {
void postDividend(Long dividendDetailId, Long savingsId);
/**
* Writes off an unclaimed share-dividend liability (see {@code m_share_dividend_lock}) to the
* bank's profit — called once a lock's 3-year release_due_date has passed with no member claim
* or manager action.
*/
void forfeitDividendLock(Long lockId);
}
@@ -18,8 +18,12 @@
*/
package org.apache.fineract.portfolio.shareaccounts.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
@@ -27,14 +31,22 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendDetails;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendStatusType;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
private final ShareAccountDividendRepository shareAccountDividendRepository;
private final SavingsAccountDomainService savingsAccountDomainService;
private final SavingsAccountAssembler savingsAccountAssembler;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
@@ -49,4 +61,69 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
this.shareAccountDividendRepository.saveAndFlush(shareAccountDividendDetails);
}
/**
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
* account up front -- it stays purely a "Dividend Payable" GL liability (see
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
* dividend past its 3-year deadline has no savings hold to release and no account balance to
* deduct -- the liability is simply written off directly against the bank's profit.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void forfeitDividendLock(final Long lockId) {
final Map<String, Object> lock = this.jdbcTemplate.queryForList("""
SELECT dividend_detail_id, currency_code, amount, status
FROM m_share_dividend_lock WHERE id = ?
""", lockId).stream().findFirst().orElseThrow();
if (!"UNCLAIMED".equals(lock.get("status"))) {
return;
}
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal amount = (BigDecimal) lock.get("amount");
postForfeitureJournalEntry(lockId, currencyCode, amount);
this.jdbcTemplate.update(
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
}
/**
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
* Reads currency straight off the lock row (set from the share account at creation time) --
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
* both GL accounts yet, exactly like the declaration-time Reserve entry.
*/
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
final Long payableDividendsAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long forfeitedIncomeAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVFORFEIT" + lockId;
final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
}
@@ -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()) //
@@ -36,6 +36,7 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
@@ -112,8 +113,10 @@ public class ShareAccountsConfiguration {
@Bean
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler);
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
context, jdbcTemplate);
}
@Bean
@@ -42,6 +42,15 @@ public class ShareProductDividentPayOutDetailsRepositoryWrapper {
this.shareProductDividentPayOutDetailsRepository.save(shareProductDividendPayOutDetails);
}
/**
* Like {@link #save}, but flushes immediately so the generated id is guaranteed to be
* populated on the entity before the caller reads it (plain save() can leave a newly
* INSERTed entity's identity-generated id unset until the surrounding transaction flushes).
*/
public void saveAndFlush(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(shareProductDividendPayOutDetails);
}
public void delete(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
this.shareProductDividentPayOutDetailsRepository.delete(shareProductDividendPayOutDetails);
}
@@ -115,6 +115,13 @@ public class ShareProductDividendAssembler {
numberOfShares += purchasedSharesData.getNumberOfShares();
} else {
numberOfShares -= purchasedSharesData.getNumberOfShares();
// A purchase that hadn't yet cleared the Minimum Active Period is skipped
// above (via `continue`) and never added here — but its later full/partial
// redemption is never skipped, so without this floor numberOfShares (and
// therefore this account's dividend) can go negative.
if (numberOfShares < 0) {
numberOfShares = 0;
}
}
}
@@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMappe
import com.google.gson.JsonObject;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -85,6 +86,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final CommandSourceRepository commandSourceRepository;
private final JdbcTemplate jdbcTemplate;
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
// accounting module into this constructor just for two int constants.
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
@Override
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
@@ -152,12 +159,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final BigDecimal dividendAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
if (dividendPayOutDetails == null) {
throw new DividentProcessingException("eligible.shares.not.found", "No eligible shares for creating dividends");
}
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount);
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
@@ -172,6 +184,68 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
}
/**
* A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see
* {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is
* a real reservation of that period's profit. Two declarations covering the same days would
* pay members twice for the same period, so any overlap against an existing declaration for
* this product is blocked outright.
*/
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
final LocalDate dividendPeriodEndDate) {
final Integer overlappingCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_share_product_dividend_pay_out
WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ?
""", Integer.class, productId, java.sql.Date.valueOf(dividendPeriodEndDate),
java.sql.Date.valueOf(dividendPeriodStartDate));
if (overlappingCount != null && overlappingCount > 0) {
throw new DividentProcessingException("period.overlaps.existing.dividend",
"This product already has a dividend declared for a period that overlaps " + dividendPeriodStartDate + " to "
+ dividendPeriodEndDate + ". Choose a period that doesn't overlap with an existing declaration.");
}
}
/**
* Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own
* Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just
* silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry
* pair rather than through the transaction-bound AccountingProcessorHelper (which expects a
* loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if
* the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity
* Mappings yet, so dividend declaration itself is never blocked by missing GL setup.
*/
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) {
final Long reserveAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
.stream().findFirst().orElse(null);
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (reserveAccountId == null || payableDividendsAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVRES" + dividendPayOutId;
final String description = "Reserve for Dividend declaration #" + dividendPayOutId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
// Debit reduces the equity Reserve; credit increases the Dividend Payable liability.
jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
@Override
public CommandProcessingResult approveShareProductDividend(final Long PayOutDetailId) {
try {
@@ -182,6 +256,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
dividendPayOutDetails.approveDividendPayout();
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
makeDividendsClaimable(PayOutDetailId);
return new CommandProcessingResultBuilder() //
.withEntityId(PayOutDetailId) //
.build();
@@ -191,6 +266,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
}
/**
* Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account.
* Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at
* declaration time) until the member actually comes to claim it -- at which point it can be
* split between clearing an overdue loan, a savings credit, or cash (see
* {@code DividendClaimRequestApiResource}). This just marks each member's share of the
* declaration POSTED (their entitlement is locked in, no more recalculation) and opens a
* 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather
* than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This
* is cheap enough (no per-member external command calls, just two bulk statements) to run
* synchronously in the approve request itself -- no background job needed.
*/
private void makeDividendsClaimable(final Long payOutDetailId) {
final LocalDate today = DateUtils.getBusinessLocalDate();
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
final java.sql.Date releaseDueDate = java.sql.Date.valueOf(today.plusYears(3));
// A linked savings account is no longer required to create the unclaimed-liability
// record -- disbursement destination (loan / savings / cash) is chosen when the member
// actually claims it, not up front. So every member gets treated identically here, whether
// their share account has a linked savings account or not; currency is read straight off
// the share account itself.
jdbcTemplate.update("""
INSERT INTO m_share_dividend_lock
(dividend_detail_id, client_id, savings_account_id, currency_code, amount, locked_date, release_due_date,
status, created_date, last_modified_date)
SELECT sad.id, sa.client_id, sa.savings_account_id, sa.currency_code, sad.amount, ?, ?, 'UNCLAIMED', now(), now()
FROM m_share_account_dividend_details sad
JOIN m_share_account sa ON sa.id = sad.account_id
WHERE sad.dividend_pay_out_id = ? AND sad.status = 100 AND sad.amount > 0
""", lockedDate, releaseDueDate, payOutDetailId);
// A member whose entitlement worked out to zero has nothing to claim; everyone else is now
// covered by the unclaimed-liability row above -- both get marked posted.
jdbcTemplate.update("UPDATE m_share_account_dividend_details SET status = 300 WHERE dividend_pay_out_id = ? AND status = 100",
payOutDetailId);
}
@Override
public CommandProcessingResult deleteShareProductDividend(final Long PayOutDetailId) {
try {
@@ -117,11 +117,11 @@ public class ShareProductsConfiguration {
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
ProductPendingEditChargeRepository pendingEditChargeRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository);
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
}
}
@@ -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,40 @@
<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"/>
<!-- Two branches independently used 0456-0462 for unrelated migrations (share dividend vs
KYC/AML). Both sets are already applied against real local databases under these exact
filenames, so neither is renumbered here -- Liquibase identifies a changeSet by its
(id, author, filename) triplet, not by the numeric prefix, so the duplicate numbering is
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
<include file="parts/0457_seed_forfeit_unclaimed_dividends_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0458_seed_dividend_financial_activity_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0459_speed_up_post_dividends_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0460_create_share_dividend_claim_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0461_rework_dividend_claim_for_gl_only_liability.xml" relativeToChangelogFile="true"/>
<include file="parts/0462_dividend_lock_no_savings_account_required.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>

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