Compare commits

...
6 Commits
Author SHA1 Message Date
keshav 934c11bf87 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
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 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 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 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 - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (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-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-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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
2026-09-18 10:29:24 +05:30
keshav c1e6e036d0 feat(aml): enforce review locking and sync STR case conclusions to linked alerts 2026-09-18 10:29:17 +05:30
niteshandClaude Sonnet 5 4c0512f154 feat(shares, cashier): tag GL counter entries by purpose, close DD/Share voucher cross-use
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-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
A cashier Receipt or clerk Collection into a GL account never named who or what it was for,
so a manager applying a share request could adopt a voucher a cashier had actually collected
for a Demand Draft, and vice versa. Adds a GLEntryType code (General/Share Collection/DD Issue,
extensible via Manage Codes) that the counter sets at save time; Share Advance Collection and
Sponsor DD issuance both check it before adopting a voucher, and also cross-check each other's
own consumption tables directly so the guard holds even for untagged legacy rows.

Also:
- Share-Collection-tagged vouchers auto-surface on Apply Share / Additional Share screens
  instead of requiring a manual GL-account search, but only when a matching client_id already
  exists on the row or the request is scoped to a specific member's screen -- a clientless GL
  receipt is never auto-attributed to whoever's screen happened to be open.
- gl_transaction_id column on m_clerk_disbursement_request captures the real GL posting a
  Collection approval makes, previously generated and discarded.
- New GET .../gl-entries endpoint exposes a receipt's actual Dr/Cr legs for detail popups.
- Additional-share tranches restricted to a single funding voucher for now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:39:59 +05:30
niteshandClaude Opus 5 b779d04571 fix(accounting): ledger transaction-id integrity, share GL correctness, and validation fixes
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 5 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract 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 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 4 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-1) (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-5) (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
Ledger transaction ids:
- New LedgerTransactionId generator (PREFIX-YYMMDD-NNNN, e.g. DIVRND-260916-0001) backed by a
  dedicated m_ledger_txn_sequence counter (migration 0563) that belongs to no business table, so
  no table's sequence reset can make an id repeat. Migrated 18 hand-rolled id sites (dividend,
  branch day, closure, year-end, share collection/advance) off ad hoc nanoTime()/PK-concatenation
  strings.
- ShareAccount.getShareAccountTransaction() matched a just-saved transaction by date+shares+price+
  type and took the first hit -- two identical transactions on the same day resolved to the wrong
  one and posted under the older transaction's id. Now matches by id where one exists.

Share accounting -- Cash at Vault fully retired from the share account/product lifecycle:
- New ShareAccountingSuppression lets a register that posts its own accounting tell the platform
  not to post its native version (mirrors VoucherService.withOwnVoucher). Wired into share
  withdrawal, additional-shares apply/approve/reject and account close, so every governed flow now
  posts one balanced entry against the real counterparty instead of the platform's fixed Shares
  Reference placeholder, reversed and reposted.
- Modify Application and Reject (the account's first application, pre-approval) no longer post via
  the native bridge at all -- no money has moved at that stage, so there was nothing to post.
- The native "close" command no longer force-redeems a share account's remaining holding in one
  unguarded step; closing with a balance outstanding is now refused, so redemption can only happen
  through the governed Share Withdrawal flow the frontend already restricts it to.
- "Share Reference (Cash Vault or Suspense)" is optional on the product, not mandatory: validator,
  GL mapping write/merge (including removing a stale mapping when the field is cleared on edit),
  and AccountingProcessorHelper's resolver (now throws a named error instead of NPEing when a
  product carries no mapping) all updated together. Removed from the product UI entirely (create,
  edit, preview, view).
- AdditionalShareTrancheFundingService: submit posts nothing; settle() posts the whole entry itself,
  debiting whichever GL a voucher receipt actually landed in (read back from its own posting) or the
  member's savings account, crediting Share Capital. A voucher-funded tranche whose receipts total
  more than it costs now credits the difference to the member's linked savings account -- every
  named receipt is visited, not just enough to cover the tranche, since one left untouched here was
  still marked APPLIED and its money left untraceable.
- ShareAccountDataSerializer: redeeming every share down to zero is exempt from the product's
  minimum-shares check (the check protects an account staying open with a reduced holding, not one
  being closed via a full exit); an over-payment on a voucher-funded tranche is refused at submit
  when no savings account is linked to receive it, rather than accepted and left unable to settle;
  six failWithCode(code, message) calls in the tranche validator were silently discarding their own
  message for the generic fallback text (failWithCode's second argument is a template arg, not a
  displayed string) -- switched to failWithCodeAndMessage.

Removed: ShareCollectionApiResource, a direct-cash counter-collection endpoint that read
m_share_account.cashier_collected, a column that has never existed in this schema -- no call site
in this codebase's frontend reached it, and no entry it could have produced ever has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:54:04 +05:30
niteshandClaude Opus 5 184d1b8cf8 feat(accounting): GL-funded DD issuance, share-withdrawal GL settlement, and ledger txn-id hardening
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 Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (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 - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-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-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
Share withdrawal (clerk):
- Add GL and NONE settlement modes alongside the existing SAVINGS mode. A clerk
  can now credit any GL account on the CR row of a Fund Transfer instead of the
  member's linked savings account; picking the product's own Sundry account
  self-cancels, so it downgrades to NONE and the request stands on leg 1 alone.
- Post Dr Sundry / Cr chosen GL for GL mode; raise a standalone Dr Share /
  Cr Sundry voucher for NONE mode, read back from the ledger.

Sponsor DD:
- Let a clerk fund a Demand Draft by debiting a GL account directly
  (payment_method = GL) under the existing maker-checker flow, reusing the
  shared postGlOffsetEntry() so only the debit-side GL resolution differs.

Cash reports and vouchers:
- Fix the Closing Cash Report total row and opening-balance carry-forward.
- Read the real till GL back from the underlying transaction when raising
  withdrawal, closure and loan vouchers, instead of labelling every leg
  "Cash in Hand".

Ledger transaction-id hardening:
A derived transaction_id text (e.g. "SH" + share transaction id) is not
guaranteed unique if the underlying numeric id is ever reused, which a manual
sequence reset can cause. Queries that read those rows back now disambiguate,
and flag flips can no longer touch an unrelated transaction:
- Read-back sites pick the most recently posted matching rows (ORDER BY id DESC
  + LIMIT) or, where the read happens long after posting, the candidate group
  whose created_date sits closest to the owning request's actioned_at.
- Every UPDATE ... SET reversed = true/false WHERE transaction_id = ? is now
  guarded by the opposite reversed state, so a collision can only flip rows
  that are currently live.
- Covers clerk disbursement, sponsor DD, cheque book, clearing, share tranche
  funding and share withdrawal.

Migrations 0551-0554: settlement GL account and voucher_no columns for the
clerk share withdrawal, dividend claim and sponsor DD issue request tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:16:17 +05:30
keshav 521d61d6b7 feat: AML alert rule disposition, Client Behaviour Window evaluation, Risk Policy parameters & Board Resolution register
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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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 - 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 - 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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-16 12:50:43 +05:30
108 changed files with 7815 additions and 1081 deletions
@@ -279,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
// Optional, not mandatory like the three below: it names an account for cash physically
// received at the counter for a share purchase, which every register in this bank now
// books against the account the money actually reached instead. Left absent, a product
// simply carries no such mapping, and any code path that still needs one (a genuine
// over-the-counter collection) fails with a named "GL not mapped" error rather than
// silently assuming the till.
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
element);
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull()
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId)
.integerGreaterThanZero();
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
@@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
case NONE:
break;
case CASH_BASED:
// asset
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
// asset -- the one optional mapping here, so unlike the three below it needs its own
// handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping).
mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes);
// income
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
@@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
}
/**
* Handles the one optional share-product mapping, Shares Reference, on an edit that does not change
* the accounting rule.
*
* <p>
* The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so
* {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly
* changed" for them -- a null value there cannot happen without failing validation first. Shares
* Reference can legitimately be absent, and the shared merge helper (used across loan, savings and
* share products alike) only handles "present": it silently leaves an existing mapping row alone
* when the incoming value is null, rather than clearing it. Editing this one field out of an
* existing product would otherwise leave the product still carrying its old GL underneath, invisible
* on the edit form but still resolvable by any code path that looks it up directly.
*/
private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId,
final Map<String, Object> changes) {
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
element);
if (shareReferenceId != null) {
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
return;
}
final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId,
PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue());
if (existing != null) {
this.accountMappingRepository.delete(existing);
changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null);
}
}
}
@@ -72,6 +72,7 @@ public enum JobName {
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
; //
private final String name;
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "disposition", length = 20)
private String disposition;
/**
* The other rules this same transaction matched, comma-separated.
*
* <p>
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
* stopping them clearing one while escalating another. The rules that did not win are kept here
* so the case file still shows everything that fired.
*/
@Column(name = "also_matched_rules", length = 500)
private String alsoMatchedRules;
/**
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
*
* <p>
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
*/
@Column(name = "highest_severity")
private Integer highestSeverity;
protected AmlAlert() {}
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
@@ -94,6 +118,58 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return alert;
}
public String getAlsoMatchedRules() {
return this.alsoMatchedRules;
}
public Integer getHighestSeverity() {
return this.highestSeverity;
}
public void recordHighestSeverity(final Integer severity) {
this.highestSeverity = severity;
}
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
public void recordAlsoMatchedRules(final String ruleCodes) {
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
}
/**
* Whether this alert is being worked by somebody other than the given officer.
*
* <p>
* An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer
* reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction
* would carry two reviews, and whichever was written last would be the one on file.
*/
public boolean isHeldByAnother(final AppUser officer) {
return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null
&& !this.reviewedBy.getId().equals(officer.getId());
}
/**
* Brings the alert into line with the Principal Officer's conclusion on the case it opened.
*
* <p>
* An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is
* the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED,
* the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer
* who cleared it would have no way to see their own conclusion reflected where the alert is worked.
*
* <p>
* Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a
* second officer overturning a decision. It is the case this alert was referred into, coming back with its answer.
*/
public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) {
this.reviewedBy = principalOfficer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name();
this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name();
}
public void whitelist(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
@@ -44,4 +44,16 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
/**
* A customer's still-open alerts inside the observation window, oldest first.
*
* <p>
* What a recurring-behaviour review acts on. Restricted to RAISED and UNDER_REVIEW because a decision about the
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
* that a partial failure leaves the longest-standing ones dealt with.
*/
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
+ "AND a.status IN ('RAISED', 'UNDER_REVIEW') ORDER BY a.raisedOn ASC")
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
}
@@ -0,0 +1,163 @@
/**
* 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.useradministration.domain.AppUser;
/**
* One rule a transaction matched, and what the officer decided about that rule.
*
* <p>
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
* observation or sends the other five to an STR they do not belong in.
*
* <p>
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
* afterwards must not rewrite what the officer was looking at when they decided.
*/
@Entity
@Table(name = "m_aml_alert_rule")
public class AmlAlertRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "rule_code", nullable = false, length = 100)
private String ruleCode;
@Column(name = "risk_category")
private Integer riskCategory;
@Column(name = "is_governing", nullable = false)
private boolean governing;
@Column(name = "disposition", length = 20)
private String disposition;
@ManyToOne
@JoinColumn(name = "reviewed_by")
private AppUser reviewedBy;
@Column(name = "reviewed_on")
private LocalDateTime reviewedOn;
@Column(name = "review_notes", length = 1000)
private String reviewNotes;
/**
* Whether the officer named this rule, as against it being closed to keep the case consistent.
*
* <p>
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
* the substance of the finding.
*/
@Column(name = "directly_decided", nullable = false)
private boolean directlyDecided;
protected AmlAlertRule() {}
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
final boolean governing) {
final AmlAlertRule finding = new AmlAlertRule();
finding.alert = alert;
finding.ruleCode = ruleCode;
finding.riskCategory = riskCategory;
finding.governing = governing;
return finding;
}
/**
* @param directlyDecided
* true when the officer named this rule; false when it was closed alongside one they named
*/
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
final boolean directlyDecided) {
this.disposition = disposition.name();
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.directlyDecided = directlyDecided;
}
public boolean isDirectlyDecided() {
return this.directlyDecided;
}
public boolean isDecided() {
return this.disposition != null;
}
public boolean isEscalated() {
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
}
public Long getId() {
return this.id;
}
public AmlAlert getAlert() {
return this.alert;
}
public String getRuleCode() {
return this.ruleCode;
}
public Integer getRiskCategory() {
return this.riskCategory;
}
public boolean isGoverning() {
return this.governing;
}
public String getDisposition() {
return this.disposition;
}
public AppUser getReviewedBy() {
return this.reviewedBy;
}
public LocalDateTime getReviewedOn() {
return this.reviewedOn;
}
public String getReviewNotes() {
return this.reviewNotes;
}
}
@@ -0,0 +1,31 @@
/**
* 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.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
List<AmlAlertRule> findByAlertId(Long alertId);
/** Every finding across a page of alerts, in one read rather than one query per alert. */
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
}
@@ -0,0 +1,106 @@
/**
* 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;
/**
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
*
* <p>
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
* fortieth review cost an officer the same effort as the first and changed as little.
*
* <p>
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
* about should come back sooner, not later.
*/
public enum AmlRecurringEscalationTier {
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
ROUTINE(0, 90),
/**
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
* the reviewer is asked to consider whether the rating still fits.
*/
ELEVATED(7, 30),
/**
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
* to be taken by someone senior.
*/
SENIOR(15, 14),
/**
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
* is a decision that has to be defensible on its own - so the review stands for a week only.
*/
MANDATORY_STR(30, 7);
private final int minimumAlertCount;
private final int reviewValidityDays;
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
this.minimumAlertCount = minimumAlertCount;
this.reviewValidityDays = reviewValidityDays;
}
/** The tier a customer sitting on this many alerts falls into. */
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
AmlRecurringEscalationTier tier = ROUTINE;
for (final AmlRecurringEscalationTier candidate : values()) {
if (alertCount >= candidate.minimumAlertCount) {
tier = candidate;
}
}
return tier;
}
public int getMinimumAlertCount() {
return minimumAlertCount;
}
public int getReviewValidityDays() {
return reviewValidityDays;
}
/**
* How far the alert count may climb before a standing review is set aside and the customer returns early.
*
* <p>
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
*/
public int alertGrowthBeforeEarlyReturn() {
return switch (this) {
case ROUTINE -> 5;
case ELEVATED -> 4;
case SENIOR -> 3;
case MANDATORY_STR -> 2;
};
}
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
public boolean expectsStr() {
return this == MANDATORY_STR;
}
}
@@ -26,6 +26,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -65,6 +66,20 @@ public class AmlRecurringReview {
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
/**
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
* behaviour of lapsing the moment one more alert lands.
*/
@Column(name = "valid_until")
private LocalDate validUntil;
@Column(name = "escalation_tier", length = 20)
private String escalationTier;
/** How many of the customer's open alerts this review actually disposed of. */
@Column(name = "alerts_actioned")
private Integer alertsActioned;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
@@ -76,9 +91,22 @@ public class AmlRecurringReview {
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
// always being cleared.
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
review.escalationTier = tier.name();
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
review.alertsActioned = 0;
return review;
}
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
public void recordAlertsActioned(final int count) {
this.alertsActioned = count;
}
public Long getId() {
return id;
}
@@ -106,4 +134,35 @@ public class AmlRecurringReview {
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
public LocalDate getValidUntil() {
return validUntil;
}
public AmlRecurringEscalationTier getEscalationTier() {
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
}
public Integer getAlertsActioned() {
return alertsActioned;
}
/**
* Whether this review still holds the customer out of the review queue.
*
* <p>
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
* lapsing as soon as one more alert arrives.
*/
public boolean stillStands(final int currentAlertCount) {
if (validUntil == null) {
return currentAlertCount <= alertCountAtReview;
}
if (LocalDate.now().isAfter(validUntil)) {
return false;
}
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
}
}
@@ -0,0 +1,83 @@
/**
* 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;
/**
* One alert covered by an STR case.
*
* <p>
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
* Principal Officer has to be able to read in one place.
*/
@Entity
@Table(name = "m_aml_str_case_alert")
public class AmlStrCaseAlert {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "str_case_id", nullable = false)
private AmlStrCase strCase;
@ManyToOne(optional = false)
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "added_on_utc", nullable = false)
private LocalDateTime addedOn;
protected AmlStrCaseAlert() {}
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
final AmlStrCaseAlert link = new AmlStrCaseAlert();
link.strCase = strCase;
link.alert = alert;
link.addedOn = LocalDateTime.now();
return link;
}
public Long getId() {
return id;
}
public AmlStrCase getStrCase() {
return strCase;
}
public AmlAlert getAlert() {
return alert;
}
public LocalDateTime getAddedOn() {
return addedOn;
}
}
@@ -0,0 +1,40 @@
/**
* 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 java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
/** The alerts one case covers, oldest first - the order the pattern happened in. */
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
/**
* The case already covering an alert, if any.
*
* <p>
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
* one, however it comes to be escalated again.
*/
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
long countByStrCase_Id(Long strCaseId);
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
long countByClient_Id(Long clientId);
/** The case already open for a transaction, so escalating it again does not open a second one. */
Optional<AmlStrCase> findByAlertId(Long alertId);
List<AmlStrCase> findByStatus(String status);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransacti
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
/**
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
*/
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
}
@@ -0,0 +1,219 @@
/**
* 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.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* What one customer's money actually did over a period, and what that says about their rating.
*
* <p>
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
* Neither is a pattern.
*
* <p>
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
* the system was looking at rather than a conclusion with no working.
*/
@Entity
@Table(name = "m_client_behaviour_window")
public class ClientBehaviourWindow {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "window_from", nullable = false)
private LocalDate windowFrom;
@Column(name = "window_to", nullable = false)
private LocalDate windowTo;
@Column(name = "txn_count")
private Integer txnCount;
@Column(name = "distinct_months")
private Integer distinctMonths;
@Column(name = "credit_total", precision = 19, scale = 6)
private BigDecimal creditTotal;
@Column(name = "debit_total", precision = 19, scale = 6)
private BigDecimal debitTotal;
@Column(name = "largest_txn", precision = 19, scale = 6)
private BigDecimal largestTxn;
@Column(name = "cash_txn_count")
private Integer cashTxnCount;
@Column(name = "cash_total", precision = 19, scale = 6)
private BigDecimal cashTotal;
@Column(name = "large_txn_count")
private Integer largeTxnCount;
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
@Column(name = "income_ratio", precision = 19, scale = 6)
private BigDecimal incomeRatio;
@Column(name = "alert_count")
private Integer alertCount;
@Column(name = "str_case_count")
private Integer strCaseCount;
@Column(name = "risk_category_at_review")
private Integer riskCategoryAtReview;
/** UP, DOWN or HOLD. */
@Column(name = "suggested_direction", length = 10)
private String suggestedDirection;
@Column(name = "suggested_category")
private Integer suggestedCategory;
@Column(name = "suggestion_reason", length = 500)
private String suggestionReason;
@Column(name = "computed_on")
private LocalDateTime computedOn;
protected ClientBehaviourWindow() {}
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
final ClientBehaviourWindow window = new ClientBehaviourWindow();
window.clientId = clientId;
window.windowFrom = windowFrom;
window.windowTo = windowTo;
window.computedOn = LocalDateTime.now();
return window;
}
public void recordDistinctMonths(final Integer months) {
this.distinctMonths = months;
}
public Integer getDistinctMonths() {
return this.distinctMonths;
}
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
this.txnCount = txnCount;
this.creditTotal = creditTotal;
this.debitTotal = debitTotal;
this.largestTxn = largestTxn;
this.cashTxnCount = cashTxnCount;
this.cashTotal = cashTotal;
this.largeTxnCount = largeTxnCount;
}
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
this.alertCount = alertCount;
this.strCaseCount = strCaseCount;
this.riskCategoryAtReview = riskCategoryAtReview;
}
/**
* Turnover measured against what the customer said to expect.
*
* <p>
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
* unknown as a benign zero would clear every customer the bank never asked.
*/
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
this.declaredAnnualIncome = income;
this.declaredAnnualTurnover = turnover;
final BigDecimal basis = income != null && income.signum() > 0 ? income
: (turnover != null && turnover.signum() > 0 ? turnover : null);
this.incomeRatio = basis == null || periodTurnover == null ? null
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
}
public void recordSuggestion(final String direction, final Integer category, final String reason) {
this.suggestedDirection = direction;
this.suggestedCategory = category;
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
}
public Long getClientId() {
return this.clientId;
}
public LocalDate getWindowFrom() {
return this.windowFrom;
}
public LocalDate getWindowTo() {
return this.windowTo;
}
public Integer getTxnCount() {
return this.txnCount;
}
public BigDecimal getCashTotal() {
return this.cashTotal;
}
public Integer getLargeTxnCount() {
return this.largeTxnCount;
}
public Integer getAlertCount() {
return this.alertCount;
}
public Integer getStrCaseCount() {
return this.strCaseCount;
}
public Integer getRiskCategoryAtReview() {
return this.riskCategoryAtReview;
}
public String getSuggestedDirection() {
return this.suggestedDirection;
}
public Integer getSuggestedCategory() {
return this.suggestedCategory;
}
}
@@ -0,0 +1,43 @@
/**
* 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.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
List<ClientBehaviourWindow> findByClientId(Long clientId);
/**
* Clears the windows computed for a given day before the job writes that day's.
*
* <p>
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
* set of windows rather than a customer appearing several times on the review screen with slightly different
* figures.
*/
@Modifying
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
}
@@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "form15_fy", length = 10)
private String form15Fy;
// ── Risk profile ─────────────────────────────────────────────────────────
/**
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
*
* <p>
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
*/
@Column(name = "is_pep", nullable = false)
private boolean isPep;
@Column(name = "sanctions_match", nullable = false)
private boolean sanctionsMatch;
@Column(name = "adverse_media_match", nullable = false)
private boolean adverseMediaMatch;
/** What the customer told the bank to expect, which their actual turnover is measured against. */
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
// ── CKYCRR ───────────────────────────────────────────────────────────────
/** Central KYC Records Registry number issued by CERSAI */
@Column(name = "ckyc_number", length = 14)
@@ -527,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.aadhaarReceiptRemarks = v);
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
v -> this.aadhaarReceiptTakenOn = v);
// Risk profile. These are determinations about the customer rather than about a transaction, and the
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
// period's activity looks.
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
v -> this.sanctionsMatch = v != null ? v : false);
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
v -> this.adverseMediaMatch = v != null ? v : false);
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
v -> this.declaredAnnualIncome = v);
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
v -> this.declaredAnnualTurnover = 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);
@@ -898,6 +936,26 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
recalculateExpiryForRiskCategory();
}
public boolean isPep() {
return this.isPep;
}
public boolean isSanctionsMatch() {
return this.sanctionsMatch;
}
public boolean isAdverseMediaMatch() {
return this.adverseMediaMatch;
}
public BigDecimal getDeclaredAnnualIncome() {
return this.declaredAnnualIncome;
}
public BigDecimal getDeclaredAnnualTurnover() {
return this.declaredAnnualTurnover;
}
public String getPanNumber() {
return panNumber;
}
@@ -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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
*
* <p>
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
* three times a month needs different figures from one lending to traders.
*/
@Entity
@Table(name = "m_risk_policy_parameter")
public class RiskPolicyParameter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parameter_key", nullable = false, length = 80)
private String parameterKey;
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
private BigDecimal parameterValue;
@Column(name = "description", length = 500)
private String description;
@Column(name = "last_modified_by")
private Long lastModifiedBy;
@Column(name = "last_modified_on")
private LocalDateTime lastModifiedOn;
protected RiskPolicyParameter() {}
public String getParameterKey() {
return this.parameterKey;
}
public BigDecimal getParameterValue() {
return this.parameterValue;
}
public String getDescription() {
return this.description;
}
}
@@ -0,0 +1,23 @@
/**
* 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 org.springframework.data.jpa.repository.JpaRepository;
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
@@ -1468,6 +1468,13 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
// Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can
// now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so
// this has to fail with a clean, named error instead of NPEing on accountMapping.
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId,
CashAccountsForShares.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -631,7 +631,12 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled");
final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled");
if (cashBasedAccountingEnabled) {
// A register that knows the transaction's real counterparty posts the entry itself, in one
// balanced pair against that account, and says so by wrapping the command in
// ShareAccountingSuppression.withOwnShareEntries(). Posting here as well would add a second
// entry for the same money, booked against the product's fixed Shares Reference account --
// which is what that register is avoiding in the first place.
if (cashBasedAccountingEnabled && ShareAccountingSuppression.shouldPostShareEntries()) {
final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled,
accrualBasedAccountingEnabled);
final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory
@@ -0,0 +1,157 @@
/**
* 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.journalentry.service;
import java.sql.Date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Mints the transaction id that ties the rows of one posting together in acc_gl_journal_entry.
*
* <p>
* Every feature here that posts a journal entry by direct JDBC used to invent this id by hand, and the two
* shapes that emerged were each wrong in their own way. Some concatenated a business row's own primary key
* ({@code "DIVROUND" + dividendPayOutId}); a primary key is only unique while its sequence keeps climbing,
* so a reset hands the same number out twice - which is exactly how two unrelated share withdrawals came to
* share one transaction id and render as a single doubled voucher. Others concatenated
* {@code System.nanoTime()}, which is unique but says nothing to whoever reads it off a screen
* ({@code "CASHMIG-1-20260913-923935527932300"}).
*
* <p>
* This generator replaces both with one readable shape, {@code PREFIX-YYMMDD-NNNN}:
*
* <pre>
* SHWGL-260916-0001 the first share-withdrawal GL settlement posted on 16 Sep 2026
* DIVRND-260916-0007 the seventh dividend rounding posted that day
* </pre>
*
* The prefix names the event, the date is the tenant's business date, and the serial restarts at 0001 each
* day. That makes a transaction id self-describing: a reader can tell what it was and when it happened
* without looking anything up.
*
* <p>
* Uniqueness does not depend on any business table. The serial comes from {@code m_ledger_txn_sequence}, a
* counter that belongs to no entity, so no entity's sequence can make it repeat. The counter is bumped by a
* single {@code INSERT ... ON CONFLICT DO UPDATE}, which takes and releases the row lock atomically, so two
* users posting at the same instant are serialised by the database rather than handed the same number - the
* same mechanism {@code m_voucher_sequence} already uses for voucher numbers.
*
* <p>
* The counter alone would still repeat if that table were ever truncated or rewound by hand, which is
* precisely the accident being defended against, so the minted id is also checked against the ledger before
* being handed back and the counter is advanced past anything already in use. That makes a duplicate
* impossible by construction rather than by convention: the id is only returned once no row holds it.
*
* <p>
* Ids already in the ledger keep their old shape. Nothing reconstructs a custom id to find its rows again
* (every call site mints one, posts with it, and never derives it a second time), so old and new shapes
* coexist without anything needing to read both.
*
* <p>
* Postings made by Fineract's own command path are not covered here and keep their native
* {@code "S"/"L"/"SH"/"C" + transaction id} form, which {@code AccountingProcessorHelper} derives and
* several readers reconstruct. Those readers are hardened at the query instead, by resolving to the most
* recently posted rows rather than assuming the text is unique.
*/
public final class LedgerTransactionId {
/** Guards against an impossible-in-practice runaway if the ledger and the counter disagree. */
private static final int MAX_PROBES = 50;
private static final DateTimeFormatter YYMMDD = DateTimeFormatter.ofPattern("yyMMdd");
private LedgerTransactionId() {}
/**
* Returns the next transaction id for this kind of posting, e.g. {@code SHWGL-260916-0001}.
*
* @param prefix
* short uppercase code naming the event, from {@link LedgerTxnPrefix}
*/
public static String next(final JdbcTemplate jdbcTemplate, final String prefix) {
// Tenant timezone, not the server's - the day the counter resets on has to be the branch's
// business day, otherwise a late-evening posting lands in the wrong series.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String datePart = today.format(YYMMDD);
for (int probe = 0; probe < MAX_PROBES; probe++) {
final int serial = nextSerial(jdbcTemplate, prefix, today);
final String candidate = String.format("%s-%s-%04d", prefix, datePart, serial);
if (!isTaken(jdbcTemplate, candidate)) {
return candidate;
}
// Only reachable if the counter was rewound behind the ledger by hand. Bumping again walks
// it forward until it clears whatever is already posted, which self-heals the counter.
}
throw new IllegalStateException("Could not mint a free ledger transaction id for prefix " + prefix + " after " + MAX_PROBES
+ " attempts. The m_ledger_txn_sequence counter for this prefix is far behind the entries already posted today.");
}
/**
* Returns the id for a posting that has to be reproducible, e.g. {@code SAVINT-260916-P0002}.
*
* <p>
* Some postings are not one-shot: a pooled interest run pays hundreds of members, and a run that
* stops halfway is resumed rather than restarted. Those resumed legs belong to the posting that
* started them, so the id has to come out the same on the second pass - a counter would hand the
* remainder a different id and split one posting across two transactions. This builds the id from
* what identifies the run instead: the same prefix, date and discriminator always produce the same
* string, and no counter is consumed.
*
* <p>
* Uniqueness rests on the caller's own discriminator being unique for that prefix and date (the
* product being posted, say), which is what makes the id reproducible in the first place. Use
* {@link #next} for everything else - a posting that happens once wants a counter, not a formula.
* The {@code P} marks the tail as a discriminator so it is not misread as a serial.
*
* @param date
* the date the posting belongs to, which is not always today - interest can be posted
* for an earlier day
* @param discriminator
* what makes this run distinct from another on the same date, e.g. the product id
*/
public static String forDatedSeries(final String prefix, final LocalDate date, final long discriminator) {
return String.format("%s-%s-P%04d", prefix, date.format(YYMMDD), discriminator);
}
/**
* Reserves the next counter value for one (prefix, day) series.
*
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use of the day and
* increments it thereafter. Being a single statement it is atomic, so no explicit lock is needed.
*/
private static int nextSerial(final JdbcTemplate jdbcTemplate, final String prefix, final LocalDate day) {
return jdbcTemplate.queryForObject("""
INSERT INTO m_ledger_txn_sequence (prefix, series_date, last_number)
VALUES (?,?,1)
ON CONFLICT (prefix, series_date)
DO UPDATE SET last_number = m_ledger_txn_sequence.last_number + 1
RETURNING last_number
""", Integer.class, prefix, Date.valueOf(day));
}
private static boolean isTaken(final JdbcTemplate jdbcTemplate, final String transactionId) {
final Boolean taken = jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM acc_gl_journal_entry WHERE transaction_id = ?)",
Boolean.class, transactionId);
return Boolean.TRUE.equals(taken);
}
}
@@ -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.accounting.journalentry.service;
/**
* The event codes that open a transaction id minted by {@link LedgerTransactionId}.
*
* <p>
* They live together in one place so a reader can decode any transaction id on a screen from a single list,
* and so no two features can drift into naming the same event differently - which had already happened:
* dividend forfeiture was posting under {@code DIVFORF} from one service and {@code DIVFORFEIT} from
* another, for the same event.
*
* <p>
* Codes are uppercase, up to eight characters, and grouped by the module that raises them. A code is part of
* the audit trail once it has been posted, so treat these as append-only: add a new one for a new event
* rather than renaming an existing one, which would leave already-posted entries speaking a language nothing
* else uses any more.
*/
public final class LedgerTxnPrefix {
private LedgerTxnPrefix() {}
// A clerk's share withdrawal has no code here on purpose: it posts under the transaction id the
// native redeem command already minted ("SH" + share transaction id), because its debit leg is
// that command's own and the two have to group as one balanced entry.
// ── Share tranche funding ─────────────────────────────────────────────────
/** Additional share tranche: reversal of the stock reference posting. */
public static final String SHARE_TRANCHE_REFERENCE = "SHTREF";
/** Additional share tranche funded from the member's savings account. */
public static final String SHARE_TRANCHE_SAVINGS = "SHTSAV";
// ── Share collection and advances ─────────────────────────────────────────
/** Share application money collected. */
public static final String SHARE_COLLECTION = "SHCOL";
/** Share advance collected against a future allotment. */
public static final String SHARE_ADVANCE = "SHADV";
/** Share advance applied to an allotment. */
public static final String SHARE_ADVANCE_APPLIED = "SHAPP";
// ── Dividend ──────────────────────────────────────────────────────────────
/** Dividend claim paid out to a member. */
public static final String DIVIDEND_CLAIM = "DIVCLM";
/** Dividend declaration posted. */
public static final String DIVIDEND_DECLARED = "DIVDCL";
/** Dividend declaration approved. */
public static final String DIVIDEND_APPROVED = "DIVAPR";
/** Dividend payout closed off. */
public static final String DIVIDEND_CLOSED = "DIVCLS";
/** Rounding difference on a dividend payout. */
public static final String DIVIDEND_ROUNDING = "DIVRND";
/** Unclaimed dividend forfeited. */
public static final String DIVIDEND_FORFEIT = "DIVFRF";
// ── Branch day and cash ───────────────────────────────────────────────────
/** Opening cash migrated into the branch day register. */
public static final String CASH_MIGRATION = "CSHMIG";
/** Rounding difference on a clerk's day closure. */
public static final String CLOSURE_ROUNDING = "CLSRND";
// ── Year end ──────────────────────────────────────────────────────────────
/** Year-end transfer of income into the profit account. */
public static final String YEAR_END_PROFIT = "YEPFT";
/** Year-end closure of an income or expense account. */
public static final String YEAR_END_CLOSE = "YECLS";
// ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
}
@@ -0,0 +1,83 @@
/**
* 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.journalentry.service;
import java.util.function.Supplier;
/**
* Lets a register that posts a share transaction's accounting itself tell the platform not to post its
* own.
*
* <h3>Why this exists</h3>
*
* <p>
* Fineract books every share transaction against a fixed "Shares Reference" account taken from the
* product's GL mapping: a redemption posts Dr Shares Equity / Cr Shares Reference, whatever the money
* actually did. That account is a placeholder for a counterparty the platform does not know, and in a
* real core banking ledger there is no such thing — a redemption paid in cash credits the cash
* account, and one settled by transfer credits the account it was transferred to. Naming a single
* asset account for both leaves every redemption sitting against a GL the money never touched, which
* then has to be corrected by hand.
*
* <p>
* The governed clerk registers already know the real counterparty, because the clerk keyed it: the
* linked savings account, or the GL picked on the transfer's credit row. They post the redemption
* themselves, correctly, in one balanced entry. Before this flag existed they had to let the platform
* post its version first and then reverse the half that was wrong, which left a dead Cr Shares
* Reference row behind on every redemption and made the ledger read as though cash had moved.
*
* <h3>Why a flag and not the product's accounting rule</h3>
*
* <p>
* Turning the share product's accounting off entirely ({@code m_share_product.accounting_type = NONE})
* would silence the platform everywhere, including the share account screen's own Redeem action, which
* no register covers — those redemptions would post nothing at all and go unnoticed, because that
* branch fails silently by design. Scoping the suppression to the one call that is about to post its
* own entry leaves every other path exactly as it is.
*
* <p>
* A ThreadLocal rather than a request attribute, and restoring the previous value rather than clearing
* it, for the same reasons as {@code VoucherService.withOwnVoucher} — a batch job has no request to
* hang the flag on, and nesting must not switch the suppression off early for an outer caller.
*/
public final class ShareAccountingSuppression {
private static final ThreadLocal<Boolean> CALLER_POSTS_SHARE_ENTRIES = ThreadLocal.withInitial(() -> Boolean.FALSE);
private ShareAccountingSuppression() {}
/**
* Runs {@code command} with the platform's own share journal entries suppressed, for a caller that
* posts the transaction's accounting itself.
*/
public static <T> T withOwnShareEntries(final Supplier<T> command) {
final boolean previous = CALLER_POSTS_SHARE_ENTRIES.get();
CALLER_POSTS_SHARE_ENTRIES.set(Boolean.TRUE);
try {
return command.get();
} finally {
CALLER_POSTS_SHARE_ENTRIES.set(previous);
}
}
/** False only while a caller inside {@link #withOwnShareEntries} is posting its own entries. */
public static boolean shouldPostShareEntries() {
return !CALLER_POSTS_SHARE_ENTRIES.get();
}
}
@@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -117,7 +119,7 @@ public class IncomeProfitTransferService {
final String currencyCode = resolveCurrency(incomeGlId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YEPROFIT-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_PROFIT);
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
@@ -210,7 +212,7 @@ public class IncomeProfitTransferService {
final String currencyCode = resolveCurrency(glId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_CLOSE);
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
@@ -61,6 +61,8 @@ public interface ShareAccountApiConstants {
String externalid_paramname = "externalId";
String resolutionnumber_paramname = "resolutionNumber";
/** Date of the board meeting that passed the resolution. */
String resolutiondate_paramname = "resolutionDate";
String currency_paramname = "currencyCode";
@@ -146,7 +148,7 @@ public interface ShareAccountApiConstants {
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,
resolutionnumber_paramname, resolutiondate_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,
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.api;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -30,4 +31,13 @@ public class AlertReviewRequest {
private String disposition;
private String notes;
/**
* The rules being decided, when the officer is deciding some of them rather than the whole alert.
*
* <p>
* Absent or empty means the decision covers the transaction and every rule it matched, which is what the row-level
* Whitelist and Suspicious buttons send and what this endpoint has always done.
*/
private List<String> ruleCodes;
}
@@ -71,7 +71,13 @@ public class AmlAlertApiResource {
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
final boolean escalating = "ESCALATE".equalsIgnoreCase(request.getDisposition());
if (request.getRuleCodes() != null && !request.getRuleCodes().isEmpty()) {
// Deciding named rules within the alert. The alert's own status follows from what is left undecided, so
// it is not set here.
this.writePlatformService.reviewRules(alertId, request.getRuleCodes(), escalating ? "ESCALATE" : "WHITELIST",
request.getNotes());
} else if (escalating) {
this.writePlatformService.escalate(alertId, request.getNotes());
} else {
this.writePlatformService.whitelist(alertId, request.getNotes());
@@ -72,6 +72,17 @@ public class AmlCtrReportApiResource {
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
}
@GET
@Path("pending-lines")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reportable cash transactions not yet in any generated CTR",
description = "The monthly batch is a snapshot, so a transaction posted after it ran belongs to that month's filing but sits in no report. These are those transactions, shaped as lines, so a reportable one is never invisible.")
public String retrievePendingLines() {
this.context.authenticatedUser();
return this.lineJsonSerializer.serialize(this.readPlatformService.retrievePendingLines());
}
@POST
@Path("generate")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,172 @@
/**
* 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 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.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.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.service.ClientRiskChangeRequestService;
import org.springframework.stereotype.Component;
/**
* Moving a customer's KYC risk category, under maker-checker.
*
* <p>
* Raised from the Risk Review Report, which already works out which customers are rated below what
* their behaviour warrants. The rating moves only when a checker approves, and the change is written
* into the customer's risk history with the reason given.
*/
@Path("/v1/aml/risk-change-requests")
@Component
@Tag(name = "Client Risk Category Change",
description = "Maker-checker changes to a customer's KYC risk category, raised from the Risk Review Report.")
@RequiredArgsConstructor
public class ClientRiskChangeApiResource {
private final PlatformSecurityContext context;
private final ClientRiskChangeRequestService service;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* Business-rule failures in this module are plain IllegalArgument/IllegalState exceptions, which
* Fineract's global handler does not map - left uncaught they reach the client as an opaque 500
* with no message. Wrapped so the reviewer is told what actually stopped them.
*/
private <T> T safely(final Supplier<T> action) {
try {
return action.get();
} catch (final IllegalArgumentException | IllegalStateException e) {
throw apiError(400, e.getMessage());
}
}
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(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List risk category change requests",
description = "Optionally filtered by status (PENDING/APPROVED/REJECTED) for the approval queue.")
public String retrieveAll(@QueryParam("status") final String status) {
this.context.authenticatedUser();
return this.gson.toJson(this.service.retrieveAll(status));
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Request a change to a customer's risk category",
description = "Maker step. Nothing moves until a checker approves.")
public CommandProcessingResult request(final RiskChangeRequest request) {
return safely(() -> this.service.request(request.getClientId(), request.getRequestedCategory(), request.getReason()));
}
@POST
@Path("{requestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a risk category change",
description = "Checker step: applies the new category to the customer's KYC record, moves the re-KYC due date onto that category's cycle, and writes the change into the risk history.")
public CommandProcessingResult approve(@PathParam("requestId") final Long requestId) {
return safely(() -> this.service.approve(requestId));
}
@POST
@Path("{requestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a risk category change", description = "Checker step. The customer's rating is left as it is.")
public CommandProcessingResult reject(@PathParam("requestId") final Long requestId, final RiskChangeRejectRequest request) {
return safely(() -> this.service.reject(requestId, request.getRejectionReason()));
}
/** Request bodies. Plain beans so Jackson binds them without a serializer of their own. */
public static final class RiskChangeRequest {
private Long clientId;
private Integer requestedCategory;
private String reason;
public Long getClientId() {
return clientId;
}
public void setClientId(Long clientId) {
this.clientId = clientId;
}
public Integer getRequestedCategory() {
return requestedCategory;
}
public void setRequestedCategory(Integer requestedCategory) {
this.requestedCategory = requestedCategory;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
public static final class RiskChangeRejectRequest {
private String rejectionReason;
public String getRejectionReason() {
return rejectionReason;
}
public void setRejectionReason(String rejectionReason) {
this.rejectionReason = rejectionReason;
}
}
}
@@ -30,6 +30,16 @@ import java.time.LocalDateTime;
*/
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, Integer riskCategory)
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory,
/** Other rules the same transaction matched, comma-separated. Null when only one fired. */
String alsoMatchedRules,
/** Worst severity across every rule the transaction matched, not only the governing one. */
Integer highestSeverity,
/**
* Every rule the transaction matched, governing one first, each with its description and severity. The
* comma-separated {@code alsoMatchedRules} is kept for what already reads it; this is what the screen expands
* to show, because a bare rule code does not tell an officer what was detected or how serious it is.
*/
java.util.List<AmlMatchedRuleData> matchedRules)
implements Serializable {
}
@@ -0,0 +1,43 @@
/**
* 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;
/**
* One rule a transaction matched, as the alert screen needs to show it.
*
* <p>
* An alert names only the rule it was raised under; the rest were a comma-separated list of codes and nothing more. An
* officer deciding what to do with a transaction has to know what each of those codes means and how serious it is -
* "NEW_ACCOUNT_HIGH_VALUE_TXN" says nothing on its own, and whether it is Medium or High changes the decision.
*
* @param governing
* whether this is the rule the alert was raised under. One transaction raises one alert, under whichever
* rule most warrants attention, so exactly one entry in the list carries this.
* @param riskCategory
* 1 Low, 2 Medium, 3 High. Null for a rule with no catalog ancestor - the CTR rules are statutory returns
* rather than graded suspicions, so they carry no band.
*/
public record AmlMatchedRuleData(String ruleCode, String ruleDescription, Integer riskCategory, boolean governing,
/** WHITELIST, ESCALATE, or null while this rule is still open. */
String disposition, String reviewedByUsername, String reviewNotes,
/** True where the officer named this rule; false where it was closed alongside one they named. */
boolean directlyDecided) implements Serializable {
}
@@ -18,15 +18,25 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.AmlMatchedRuleData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -38,22 +48,127 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
private final AmlAlertRepository repository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final AmlTransactionRuleRepository transactionRuleRepository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
// Nothing flagged is an ordinary state - a quiet period, or a bank that has just been set up - and every
// lookup below reads a list built from these alerts. An empty IN clause is a syntax error in Postgres, so
// without this the screen fails outright at the very moment it has nothing to report.
if (alerts.isEmpty()) {
return List.of();
}
// 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);
// Same reasoning for the also-matched rules, which are held on the alert as bare codes: one
// read for every code on the page, not one per alert.
// The findings beneath each alert - one row per rule, carrying that rule's own disposition. Read for the whole
// page at once rather than per alert.
final Map<Long, List<AmlAlertRule>> findingsByAlertId = this.alertRuleRepository
.findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream()
.collect(Collectors.groupingBy(f -> f.getAlert().getId()));
final Map<String, AmlTransactionRule> rulesByCode = rulesByCodeFor(alerts, findingsByAlertId);
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts, rulesByCode.values());
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
.map(alert -> mapToData(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)).collect(Collectors.toList());
}
/** 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();
/**
* Every rule named anywhere on this page, keyed on its code.
*
* <p>
* Covers the findings as well as the also-matched lists, because a finding holds only a code and the pane needs the
* description behind it.
*/
private Map<String, AmlTransactionRule> rulesByCodeFor(final List<AmlAlert> alerts,
final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final Set<String> codes = alerts.stream().map(AmlAlert::getAlsoMatchedRules).filter(s -> s != null && !s.isBlank())
.flatMap(s -> Arrays.stream(s.split(","))).map(String::trim).filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(java.util.HashSet::new));
findingsByAlertId.values().stream().flatMap(List::stream).map(AmlAlertRule::getRuleCode).forEach(codes::add);
alerts.stream().map(a -> a.getRule().getRuleCode()).forEach(codes::add);
if (codes.isEmpty()) {
return Map.of();
}
final Map<String, AmlTransactionRule> byCode = new HashMap<>();
this.transactionRuleRepository.findByRuleCodeIn(codes).forEach(rule -> byCode.put(rule.getRuleCode(), rule));
return byCode;
}
/**
* Every rule the transaction matched, governing one first, each carrying its description and severity.
*
* <p>
* A rule whose catalog ancestor is missing still appears, with a null severity rather than being dropped: the
* officer needs to see that it fired. A code with no rule behind it at all - a rule deleted since the alert was
* raised - keeps its code as the description, because the alert is a record of what happened and editing the rule
* set afterwards does not change that.
*/
private List<AmlMatchedRuleData> matchedRulesOf(final AmlAlert alert, final Map<Long, Integer> riskByRiskRuleId,
final Map<String, AmlTransactionRule> rulesByCode, final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final List<AmlAlertRule> findings = findingsByAlertId.get(alert.getId());
if (findings != null && !findings.isEmpty()) {
// Governing rule first, then by code, so the pane does not reorder itself between reads.
return findings.stream()
.sorted(Comparator.comparing(AmlAlertRule::isGoverning).reversed().thenComparing(AmlAlertRule::getRuleCode))
.map(f -> new AmlMatchedRuleData(f.getRuleCode(), describe(f.getRuleCode(), rulesByCode), f.getRiskCategory(),
f.isGoverning(), f.getDisposition(),
f.getReviewedBy() == null ? null : f.getReviewedBy().getUsername(), f.getReviewNotes(),
f.isDirectlyDecided()))
.collect(Collectors.toList());
}
// No findings rows: an alert raised before they existed and not covered by the backfill. Rebuilt from the flat
// fields so the pane still shows what fired, with no per-rule disposition to report.
final List<AmlMatchedRuleData> matched = new ArrayList<>();
final Long governingSourceId = alert.getRule().getSourceClientRiskRuleId();
matched.add(new AmlMatchedRuleData(alert.getRule().getRuleCode(), alert.getRule().getDescription(),
governingSourceId == null ? null : riskByRiskRuleId.get(governingSourceId), true, null, null, null, false));
final String also = alert.getAlsoMatchedRules();
if (also == null || also.isBlank()) {
return matched;
}
for (final String raw : also.split(",")) {
final String code = raw.trim();
if (code.isEmpty()) {
continue;
}
final AmlTransactionRule rule = rulesByCode.get(code);
final Long sourceId = rule == null ? null : rule.getSourceClientRiskRuleId();
matched.add(new AmlMatchedRuleData(code, describe(code, rulesByCode),
sourceId == null ? null : riskByRiskRuleId.get(sourceId), false, null, null, null, false));
}
return matched;
}
/**
* A rule's description, or its code when the rule itself is gone.
*
* <p>
* An alert records what was detected at the time; a rule retired since then does not erase the finding, so the code
* stands in rather than the row disappearing.
*/
private String describe(final String ruleCode, final Map<String, AmlTransactionRule> rulesByCode) {
final AmlTransactionRule rule = rulesByCode.get(ruleCode);
return rule == null || rule.getDescription() == null ? ruleCode : rule.getDescription();
}
/**
* Severity of each originating catalog rule, keyed on m_client_risk_rule.id.
*
* <p>
* Covers the also-matched rules as well as the governing ones. Resolving only the governing rules left every
* also-matched rule reporting a null severity, which is exactly the thing the expanded row exists to show.
*/
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts, final Collection<AmlTransactionRule> alsoMatched) {
final List<Long> sourceIds = Stream
.concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()),
alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId))
.filter(id -> id != null).distinct().toList();
final Map<Long, Integer> byId = new HashMap<>();
if (sourceIds.isEmpty()) {
return byId;
@@ -62,13 +177,15 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
return byId;
}
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId, Map<String, AmlTransactionRule> rulesByCode,
Map<Long, List<AmlAlertRule>> findingsByAlertId) {
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(), riskCategory);
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory,
alert.getAlsoMatchedRules(), alert.getHighestSeverity(), matchedRulesOf(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId));
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
@@ -27,4 +28,29 @@ public interface AmlAlertWritePlatformService {
AmlAlert whitelist(Long alertId, String notes);
AmlStrCase escalate(Long alertId, String notes);
/**
* Escalates several alerts into a single STR case.
*
* <p>
* For a case raised off a customer's repeated behaviour rather than one transaction. Escalating each alert
* separately would put one case per transaction in front of the Principal Officer, each holding a fragment of a
* pattern that only means anything taken together - and a customer reviewed again a month later would add several
* more. An STR to FIU-IND reports the activity, so the case has to hold all of it.
*
* @param alertIds
* the alerts the pattern is made of; any already covered by a case keep that case
* @return the case covering them, or null when every alert was already covered
*/
AmlStrCase escalateAsOneCase(List<Long> alertIds, String notes);
/**
* Decides individual rules within one alert, leaving the rest open.
*
* @param ruleCodes
* the rules being decided now
* @param disposition
* WHITELIST or ESCALATE, applied to each of them
*/
AmlAlert reviewRules(Long alertId, List<String> ruleCodes, String disposition, String notes);
}
@@ -19,12 +19,21 @@
package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertDisposition;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertStatus;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,28 +43,200 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
private final AmlAlertRepository alertRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlStrCaseAlertRepository strCaseAlertRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
@Override
public AmlAlert whitelist(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
final AmlAlert alert = loadForDisposal(alertId);
alert.whitelist(context.authenticatedUser(), notes);
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
return alertRepository.save(alert);
}
@Override
public AmlStrCase escalate(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
// loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one.
// An STR is about the transaction, and one transaction gets one case.
final AmlAlert alert = loadForDisposal(alertId);
alert.escalate(context.authenticatedUser(), notes);
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
alertRepository.save(alert);
return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert));
}
private AmlStrCase openStrCase(final AmlAlert alert) {
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
// Recorded in the link table as well as on alert_id, so every case describes what it covers the same way -
// a single-transaction case covering one alert, a pattern case covering several.
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
return strCase;
}
@Override
public AmlStrCase escalateAsOneCase(final List<Long> alertIds, final String notes) {
if (alertIds == null || alertIds.isEmpty()) {
return null;
}
final AppUser currentUser = context.authenticatedUser();
AmlStrCase strCase = null;
for (final Long alertId : alertIds) {
final AmlAlert alert = loadForDisposal(alertId);
// An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice
// under two references, and the older case is the one an officer may already have worked on.
final AmlStrCaseAlert existing = strCaseAlertRepository.findByAlert_Id(alertId).orElse(null);
if (existing != null) {
if (strCase == null) {
strCase = existing.getStrCase();
}
continue;
}
alert.escalate(currentUser, notes);
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
alertRepository.save(alert);
if (strCase == null) {
// Opened from the first alert that needed one, which is the oldest: the case is dated and referenced
// from where the pattern starts rather than from whichever alert happened to be escalated last.
strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
}
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
}
return strCase;
}
/**
* Records a decision against individual rules within one alert.
*
* <p>
* A transaction can trip six rules where five are explained by the customer's declared business and the sixth is
* not. Deciding those separately is the point; the alert's own status is then derived from what is left - it stays
* open while any rule is undecided, because a case with an open finding is not a closed case.
*
* <p>
* Escalating any rule opens the STR case for the transaction, and only one: an STR is about the transaction, not
* about each rule that described it. Escalating a second rule on an alert already escalated adds the finding
* without opening a duplicate case.
*/
@Override
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
final AmlAlert alert = loadForDisposal(alertId);
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
final Set<String> requested = Set.copyOf(ruleCodes);
// A finding already decided is left alone, for the same reason the alert itself is: the decision recorded
// against a rule is the bank's answer on it, and a second pass writing over it would leave the file showing
// only whatever was sent last. Asked to decide rules that are all already decided, this refuses rather than
// silently doing nothing.
final List<AmlAlertRule> targeted = findings.stream()
.filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList();
if (targeted.isEmpty()) {
throw new IllegalArgumentException(
"No open rules matching " + String.join(", ", ruleCodes) + " on alert " + alertId + " — already decided");
}
for (final AmlAlertRule finding : targeted) {
finding.decide(decision, context.authenticatedUser(), notes, true);
alertRuleRepository.save(finding);
}
// Re-read state across every finding, including the ones just written, to decide where the alert now stands.
final boolean anyEscalated = findings.stream().anyMatch(AmlAlertRule::isEscalated);
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
// Suspicion carries the transaction, whatever else is still open. Waiting for every rule to be decided meant
// an officer could mark a withdrawal suspicious and watch the row sit there as RAISED with no STR case, purely
// because a second, innocuous rule had not been clicked - the one outcome that must never be delayed. One
// suspicious finding makes the transaction suspicious; the remaining rules are then recorded against a case
// that is already open.
if (anyEscalated) {
alert.escalate(context.authenticatedUser(), notes);
// The findings left open were carried into the escalation, so nothing is left dangling behind a decided
// case - but recorded as carried, not as named. The officer condemned the rule they ticked; the rest were
// closed to keep the case consistent, and the record must not claim otherwise.
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, false);
alertRepository.save(alert);
if (!wasEscalated) {
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
}
return alert;
}
// Clearing is the opposite: it only holds once nothing is left to look at. A transaction is not cleared while
// one of its rules is still unexamined.
//
// Statutory rules are not part of that test. A CTR carries no severity because it is not a graded suspicion -
// it is a return owed on the amount, filed whether or not the customer is above suspicion - so it is never
// offered to the officer to decide. Counting it as unexamined would leave the alert permanently unclearable,
// waiting on a judgement that is not the officer's to make.
if (!findings.stream().filter(f -> f.getRiskCategory() != null).allMatch(AmlAlertRule::isDecided)) {
return alertRepository.save(alert);
}
alert.whitelist(context.authenticatedUser(), notes);
return alertRepository.save(alert);
}
/**
* Carries a whole-alert decision down to every finding still open beneath it.
*
* <p>
* Deciding the alert decides everything in it. Without this a case closed from the row would leave its findings
* blank, and the expanded pane would show a whitelisted alert whose rules all still read as undecided. Findings
* already decided individually keep the decision the officer made about them.
*/
private void decideAllFindings(final Long alertId, final AmlAlertDisposition disposition, final String notes,
final boolean directlyDecided) {
for (final AmlAlertRule finding : alertRuleRepository.findByAlertId(alertId)) {
if (!finding.isDecided()) {
finding.decide(disposition, context.authenticatedUser(), notes, directlyDecided);
alertRuleRepository.save(finding);
}
}
}
/**
* Loads an alert the current officer is entitled to dispose of.
*
* <p>
* An alert another officer has open is theirs until they decide it or hand it back. Two officers reaching their
* own verdicts on one transaction is the failure this prevents - both writes succeed, and the case ends up
* recorded as whatever the second one happened to choose. Refusing here rather than in the screen is what makes
* it a rule: the screen hides the buttons, but the endpoint is what enforces it.
*/
private AmlAlert loadForDisposal(final Long alertId) {
final AmlAlert alert = loadAlert(alertId);
// A disposition is final. Whitelisting or escalating an alert is the bank's recorded answer to a flagged
// transaction - an escalation has already opened an STR case and re-rated the customer - and letting a second
// officer write over it would leave the file saying whatever was submitted last, with the earlier decision and
// the reasoning behind it simply gone. Anything that genuinely has to be revisited is reopened deliberately,
// through the STR case, where the change is itself recorded.
final AmlAlertStatus status = alert.getStatus();
if (AmlAlertStatus.WHITELISTED.equals(status) || AmlAlertStatus.ESCALATED.equals(status)) {
throw new IllegalStateException("Alert " + alertId + " was already " + status.name().toLowerCase() + " by "
+ (alert.getReviewedBy() == null ? "another officer" : alert.getReviewedBy().getUsername())
+ " and cannot be decided again");
}
final AppUser current = context.authenticatedUser();
if (alert.isHeldByAnother(current)) {
throw new IllegalStateException("Alert " + alertId + " is being reviewed by "
+ alert.getReviewedBy().getUsername() + " and cannot be decided by another officer until they release it");
}
return alert;
}
private AmlAlert loadAlert(Long alertId) {
return alertRepository.findById(alertId)
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
@@ -26,11 +26,13 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportStatus;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
@@ -66,8 +68,21 @@ public class AmlCtrReportGenerationService {
public AmlCtrReport generateForMonth(YearMonth month) {
final String reportMonth = month.toString();
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
// A month can be rebuilt while its report is still a draft, and must not be once it has been submitted.
// Refusing outright was the safer-looking rule and the wrong one: the batch is a snapshot, so a cash
// transaction posted after it ran belonged to that month's filing and could never be added to it - the report
// was permanently short of a transaction the bank is obliged to report. Regenerating a draft replaces its
// lines with what the month now holds; a submitted report is left exactly as filed.
final Optional<AmlCtrReport> existing = ctrReportRepository.findByReportMonth(reportMonth);
if (existing.isPresent()) {
final AmlCtrReport report = existing.get();
if (AmlCtrReportStatus.SUBMITTED.equals(report.getStatus())) {
throw new IllegalStateException(
"CTR report for " + reportMonth + " has been submitted to FIU-IND and cannot be regenerated");
}
ctrReportLineRepository.deleteAll(ctrReportLineRepository.findByCtrReport_Id(report.getId()));
ctrReportRepository.delete(report);
ctrReportRepository.flush();
}
final LocalDate start = month.atDay(1);
@@ -33,4 +33,13 @@ public interface AmlCtrReportReadPlatformService {
* one month's report, or null for every generated month
*/
List<AmlCtrReportLineData> retrieveLines(Long reportId);
/**
* Reportable cash transactions that no generated report contains yet.
*
* <p>
* The monthly batch is a snapshot; anything posted after it runs belongs to that month's filing but sits in no
* report until the month is regenerated. These are those transactions, so a reportable one is never invisible.
*/
List<AmlCtrReportLineData> retrievePendingLines();
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -28,6 +29,7 @@ import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -68,6 +70,56 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
""";
/**
* Cash transactions that a CTR must name but that no generated report contains yet.
*
* <p>
* The monthly batch takes a snapshot the moment it runs. A ₹15,00,000 cash deposit posted after that run belongs
* to the same month's filing and appears nowhere - not in the report, not on the FIU screen - until someone thinks
* to regenerate. A reportable transaction that is invisible is the one failure this report cannot have, so it is
* listed here as pending: shaped exactly like a generated line, with no report behind it.
*
* <p>
* Individually-reportable transactions only. A connected group is an arithmetic fact about a whole month and
* cannot be decided while the month is still running.
*/
private static final String PENDING_LINES_SQL = """
SELECT NULL::bigint AS id, NULL::bigint AS ctr_report_id,
to_char(t.transaction_date, 'YYYY-MM') AS report_month,
'SAVINGS' AS transaction_type, t.id AS transaction_id,
c.id AS client_id, c.display_name AS client_name, t.amount, false AS is_connected_group,
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
sa.activatedon_date AS account_opened_on,
sa.closedon_date AS account_closed_on,
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
WHEN 800 THEN 'MATURED' END AS account_status,
k.ckyc_number AS ckyc_number,
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
COALESCE(c.legal_form_enum, 1) = 1 AS individual
FROM m_savings_account_transaction t
JOIN m_savings_account sa ON sa.id = t.savings_account_id
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE pt.is_cash_payment = true
AND t.is_reversed = false
AND t.transaction_type_enum IN (1, 2)
AND t.amount >= ?
AND NOT EXISTS (
SELECT 1 FROM m_aml_ctr_report_line el
WHERE el.transaction_type = 'SAVINGS' AND el.transaction_id = t.id)
ORDER BY t.transaction_date DESC, t.amount DESC, t.id
""";
/** The CTR line: ₹10,00,000 in a single cash transaction. Mirrors AmlCtrReportGenerationService. */
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
private final AmlCtrReportRepository repository;
private final JdbcTemplate jdbcTemplate;
@@ -88,18 +140,35 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual")), args.toArray());
return jdbcTemplate.query(sql.toString(), LINE_MAPPER, args.toArray());
}
/**
* Reportable cash transactions no generated report contains yet.
*
* <p>
* Shaped as lines so the FIU screen can show them beside the filed ones, but with a null id and report id: they
* are not in a filing, and nothing here pretends they are. The filing is still made by generating the month, which
* is what makes them disappear from this list.
*/
@Override
public List<AmlCtrReportLineData> retrievePendingLines() {
return jdbcTemplate.query(PENDING_LINES_SQL, LINE_MAPPER, CTR_THRESHOLD);
}
/** Shared by both queries, which select the same columns so a pending line renders like a filed one. */
private static final RowMapper<AmlCtrReportLineData> LINE_MAPPER = (rs, rowNum) -> new AmlCtrReportLineData(
rs.getObject("id") == null ? null : rs.getLong("id"),
rs.getObject("ctr_report_id") == null ? null : rs.getLong("ctr_report_id"), rs.getString("report_month"),
rs.getString("transaction_type"), rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"),
rs.getLong("client_id"), rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), rs.getString("direction"),
rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual"));
private AmlCtrReportData mapToData(AmlCtrReport report) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
@@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
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();
// A standing review holds the customer out until its window runs out, or until their behaviour has moved
// on far enough that it no longer describes them - see AmlRecurringReview#stillStands. It used to lapse on
// the very next alert, which brought a customer whose ordinary business trips a rule weekly straight back
// the day after being reviewed, for the officer to re-read the explanation they had just written.
final boolean needsReview = lastReview == null || !lastReview.stillStands(alerts.size());
if (onlyNeedingReview && !needsReview) {
continue;
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -26,6 +27,8 @@ 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.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
@@ -45,6 +48,8 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final AmlAlertRepository alertRepository;
private final AmlAlertWritePlatformService alertWritePlatformService;
@Override
@Transactional
@@ -59,9 +64,58 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
this.context.authenticatedUser());
// The outcome now does what its name says. It used to do nothing at all: all three choices wrote the same row
// and the alerts were left exactly as they were, so an officer who chose ESCALATED believed they had raised an
// STR while the alerts sat untouched and no case ever reached the Principal Officer.
final int actioned = applyOutcomeToOpenAlerts(clientId, parsedOutcome, notes.trim(), window);
review.recordAlertsActioned(actioned);
return this.reviewRepository.saveAndFlush(review).getId();
}
/**
* Carries the reviewer's conclusion through to the customer's open alerts.
*
* <pre>
* CLEARED every open alert is whitelisted - the behaviour was explained, so nothing is left outstanding
* ESCALATED every open alert is escalated, which opens an STR case for the Principal Officer
* MONITOR nothing is disposed of - the point of monitoring is that the alerts stay open
* </pre>
*
* Each alert is disposed of individually rather than in bulk, because whitelist() and escalate() already carry the
* audit trail, the per-rule findings and the STR-case handling that a review has to leave behind. Reproducing any
* of that here would be a second, quieter path to the same records.
*/
private int applyOutcomeToOpenAlerts(final Long clientId, final AmlRecurringReviewOutcome outcome, final String notes,
final int windowDays) {
if (AmlRecurringReviewOutcome.MONITOR.equals(outcome)) {
return 0;
}
final LocalDateTime since = LocalDateTime.now().minusDays(windowDays);
final List<AmlAlert> openAlerts = this.alertRepository.findOpenAlertsForClientSince(clientId, since);
final String trail = "Recurring-behaviour review: " + notes;
if (AmlRecurringReviewOutcome.ESCALATED.equals(outcome)) {
// One case for the whole pattern, not one per transaction. What is being reported is the customer's
// repeated activity, and the Principal Officer investigating it needs the transactions that make it up
// together - a case per alert would hand them four fragments of one story, and five more the next time
// this customer was reviewed.
this.alertWritePlatformService.escalateAsOneCase(openAlerts.stream().map(AmlAlert::getId).toList(), trail);
return openAlerts.size();
}
int actioned = 0;
for (final AmlAlert alert : openAlerts) {
this.alertWritePlatformService.whitelist(alert.getId(), trail);
actioned++;
}
return actioned;
}
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);
@@ -20,17 +20,27 @@ package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.data.AmlDirection;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -51,9 +61,39 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
private static final int HIGH_RISK = 3;
/**
* Rules that carry a filing obligation in their own right, whatever else the transaction also
* matched.
*
* <p>
* A CTR is not a suspicion - it is a return the bank owes FIU-IND on any cash transaction at or
* above ten lakh, and it is filed whether or not the customer is above suspicion. Letting a
* behavioural rule outrank it would bury a statutory filing behind an internal observation.
*/
private static final Set<String> STATUTORY_RULE_CODES = Set.of("CTR_THRESHOLD_10L", "LARGE_CASH_TXN_25L",
"STRUCTURED_CTR_10L_MONTHLY");
/**
* Which rule an alert is raised under when a transaction matches several.
*
* <p>
* Statutory reporting first, because those must be filed. Then the largest threshold, which is
* the system's own measure of how far out of the ordinary the transaction is - a rule written
* for ₹25,00,000 is describing something graver than one written for ₹50,000. Rule code last,
* so the same transaction always reports under the same rule rather than shifting with whatever
* order the database returned.
*/
private static final Comparator<AmlTransactionRule> RULE_PRECEDENCE = Comparator
.comparing((AmlTransactionRule r) -> STATUTORY_RULE_CODES.contains(r.getRuleCode()) ? 0 : 1)
.thenComparing(r -> r.getThresholdAmount() == null ? BigDecimal.valueOf(-1) : r.getThresholdAmount(),
Comparator.reverseOrder())
.thenComparing(AmlTransactionRule::getRuleCode);
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final ClientKycRepository clientKycRepository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
private final AmlSubjectScopeResolver subjectScopeResolver;
@@ -63,15 +103,55 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
final List<AmlAlert> raised = new ArrayList<>();
final List<AmlTransactionRule> matched = new ArrayList<>();
for (final AmlTransactionRule rule : candidates) {
if (isHit(rule, context)) {
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
raised.add(alertRepository.save(alert));
matched.add(rule);
}
}
final List<AmlAlert> raised = new ArrayList<>();
if (!matched.isEmpty()) {
// One transaction, one alert. Raising one per matched rule meant a single ₹12,00,000
// cash deposit arrived as two open alerts - the CTR and the new-account rule - and the
// officer had to dispose of each. Nothing stopped them whitelisting one while
// escalating the other, leaving the same deposit recorded as both cleared and
// suspicious. A monitoring system reports a transaction once, under the rule that most
// warrants attention.
matched.sort(RULE_PRECEDENCE);
final AmlTransactionRule governing = matched.get(0);
// When the alert was raised, not the transaction's value date. The context carries the date the
// transaction is booked to, which is a LocalDate widened to midnight - so every alert on the screen read
// "00:00", and two alerts minutes apart were indistinguishable. A monitoring feed is ordered by when it
// noticed something; the value date is still on the transaction the alert points at.
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(),
governing, context.getAmount(), context.getCurrencyCode(), DateUtils.getLocalDateTimeOfTenant());
// The rules that did not govern are still recorded: an analyst needs to see that the
// deposit was both reportable and on a new account, even though it is one case.
if (matched.size() > 1) {
alert.recordAlsoMatchedRules(
matched.stream().skip(1).map(AmlTransactionRule::getRuleCode).collect(Collectors.joining(", ")));
}
// The worst band across everything that matched, not only the rule the alert was raised
// under. A statutory CTR outranks a high-severity behavioural rule when the governing
// rule is picked, so without this the screen would see "CTR, no severity" and offer the
// transaction up for a batch decision alongside ordinary low-value alerts.
final Map<Long, Integer> bandBySourceId = severityBySourceRuleId(matched);
alert.recordHighestSeverity(highestSeverityOf(bandBySourceId));
final AmlAlert saved = alertRepository.save(alert);
// One finding per rule, so each can be disposed of on its own. Severity is copied in
// rather than read back through the rule later: the alert records what was detected at
// the time, and re-banding a rule afterwards must not rewrite that.
for (final AmlTransactionRule rule : matched) {
final Long sourceId = rule.getSourceClientRiskRuleId();
alertRuleRepository.save(AmlAlertRule.of(saved, rule.getRuleCode(),
sourceId == null ? null : bandBySourceId.get(sourceId), rule == governing));
}
raised.add(saved);
}
// 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
@@ -82,6 +162,45 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
return raised;
}
/**
* The worst severity band among the rules that matched.
*
* <p>
* A transaction rule carries no severity of its own; it inherits the risk category of the
* catalog entry it was derived from. Rules with no catalog ancestor - the CTR rules - contribute
* nothing, because a statutory return is not a graded suspicion. Null when none of the matched
* rules carried a severity at all.
*/
private Integer highestSeverityOf(final Map<Long, Integer> bandBySourceId) {
Integer worst = null;
for (final Integer band : bandBySourceId.values()) {
if (band != null && (worst == null || band > worst)) {
worst = band;
}
}
return worst;
}
/**
* Severity of each matched rule's catalog ancestor, keyed on {@code m_client_risk_rule.id}.
*
* <p>
* Read once and used twice - for the alert's own highest-severity figure and for the per-rule findings written
* beneath it - rather than querying the catalog twice for the same rows.
*/
private Map<Long, Integer> severityBySourceRuleId(final List<AmlTransactionRule> matched) {
final List<Long> sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null)
.distinct().toList();
if (sourceIds.isEmpty()) {
return Map.of();
}
final Map<Long, Integer> bandById = new HashMap<>();
for (final ClientRiskRule catalogRule : clientRiskRuleRepository.findAllById(sourceIds)) {
bandById.put(catalogRule.getId(), catalogRule.getRiskCategory());
}
return bandById;
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (!appliesToThisTransaction(rule, context)) {
return false;
@@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
private final AmlStrCaseRepository strCaseRepository;
private final AmlStrCaseAlertRepository strCaseAlertRepository;
private final AmlAlertRepository alertRepository;
private final AppUserRepository appUserRepository;
private final PlatformSecurityContext context;
private final StrSlaCalculator strSlaCalculator;
@@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
: null;
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
return strCaseRepository.save(strCase);
final AmlStrCase saved = strCaseRepository.save(strCase);
// The conclusion is the answer to the referral, so it goes back to the alerts that raised it. A case concluded
// clear leaves those transactions reading as suspicious on the AML feed until this runs - the bank would have
// decided one thing and the screen an officer works from would say another.
final boolean suspicious = conclusion == AmlStrConclusion.SUSPICIOUS;
final String note = "Principal Officer concluded " + (suspicious ? "suspicious" : "clear")
+ (investigationNotes == null || investigationNotes.isBlank() ? "" : " — " + investigationNotes);
for (final AmlAlert alert : alertsOf(saved)) {
alert.applyStrConclusion(suspicious, principalOfficer, note);
alertRepository.save(alert);
}
return saved;
}
/**
* Every alert the case covers.
*
* <p>
* Read from the link table, which holds one row per alert: a pattern case is opened over several alerts, and
* concluding it has to answer all of them rather than only the one whose id happens to sit on the case.
*/
private List<AmlAlert> alertsOf(final AmlStrCase strCase) {
final List<AmlAlert> covered = strCaseAlertRepository.findByStrCase_IdOrderByAlert_RaisedOnAsc(strCase.getId()).stream()
.map(AmlStrCaseAlert::getAlert).collect(Collectors.toList());
// Cases opened before the link table existed carry only alert_id.
if (covered.isEmpty() && strCase.getAlert() != null) {
covered.add(strCase.getAlert());
}
return covered;
}
@Override
@@ -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.aml.service;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/**
* Moving a customer's KYC risk category, under maker-checker.
*
* <p>
* The category decides how often the customer is re-KYC'd and what AML thresholds apply to them, so
* raising or lowering one is a decision about how closely the bank watches that customer. It is
* raised from the Risk Review Report - which already works out where a rating ought to sit - and
* takes effect only when a checker agrees.
*/
public interface ClientRiskChangeRequestService {
/** Raised by the reviewer. Nothing moves until a checker approves. */
CommandProcessingResult request(Long clientId, Integer requestedCategory, String reason);
/** Applies the new category to the customer's KYC record and records the change in history. */
CommandProcessingResult approve(Long requestId);
CommandProcessingResult reject(Long requestId, String rejectionReason);
/** @param status PENDING / APPROVED / REJECTED, or null for all */
List<Map<String, Object>> retrieveAll(String status);
}
@@ -0,0 +1,241 @@
/**
* 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.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ClientRiskChangeRequestServiceImpl implements ClientRiskChangeRequestService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_APPROVED = "APPROVED";
private static final String STATUS_REJECTED = "REJECTED";
/** KycRiskCategory: 1 Low, 2 Medium, 3 High. */
private static final int LOWEST_CATEGORY = 1;
private static final int HIGHEST_CATEGORY = 3;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final ClientKycRepositoryWrapper kycRepositoryWrapper;
private final ClientRiskCategoryHistoryRepository historyRepository;
@Override
@Transactional
public CommandProcessingResult request(final Long clientId, final Integer requestedCategory, final String reason) {
final AppUser currentUser = this.context.authenticatedUser();
if (requestedCategory == null || requestedCategory < LOWEST_CATEGORY || requestedCategory > HIGHEST_CATEGORY) {
throw new IllegalArgumentException("Requested risk category must be Low (1), Medium (2) or High (3).");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("A reason is required to change a customer's risk category.");
}
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
final Integer currentCategory = kyc.getRiskCategoryValue();
if (requestedCategory.equals(currentCategory)) {
throw new IllegalStateException("The customer is already on that risk category.");
}
if (!this.jdbcTemplate
.queryForList("SELECT id FROM m_client_risk_change_request WHERE client_id = ? AND status = 'PENDING'", clientId)
.isEmpty()) {
throw new IllegalStateException(
"A risk category change is already pending for this customer. Wait for it to be approved or rejected.");
}
final KeyHolder kh = new GeneratedKeyHolder();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(con -> {
final java.sql.PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_client_risk_change_request "
+ "(client_id, current_category, requested_category, reason, status, maker_id, created_date) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, client.getId());
if (currentCategory == null) {
ps.setNull(2, java.sql.Types.INTEGER);
} else {
ps.setInt(2, currentCategory);
}
ps.setInt(3, requestedCategory);
ps.setString(4, reason);
ps.setString(5, STATUS_PENDING);
ps.setLong(6, currentUser.getId());
ps.setObject(7, now);
return ps;
}, kh);
return new CommandProcessingResultBuilder().withClientId(client.getId())
.withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
}
@Override
@Transactional
public CommandProcessingResult approve(final Long requestId) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRequestOrThrow(requestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot approve your own risk category change.");
}
final Long clientId = ((Number) request.get("client_id")).longValue();
final Integer requestedCategory = ((Number) request.get("requested_category")).intValue();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
// Read again rather than trusted from the request: the rating may have moved since - an AML
// auto-escalation on the customer's third STR case does exactly that - and the history has
// to record where it actually moved from.
final Integer previousCategory = kyc.getRiskCategoryValue();
if (requestedCategory.equals(previousCategory)) {
throw new IllegalStateException(
"The customer is already on the requested risk category; the rating moved after this request was raised.");
}
// Moves the re-KYC due date onto the new category's cycle as well: a customer rated higher
// is called back in sooner, which is most of what the rating is for.
kyc.escalateRiskCategory(requestedCategory);
this.kycRepositoryWrapper.saveAndFlush(kyc);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.historyRepository.save(ClientRiskCategoryHistory.manual(client, previousCategory, requestedCategory,
(String) request.get("reason"), currentUser, now));
this.jdbcTemplate.update(
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
STATUS_APPROVED, currentUser.getId(), now, requestId);
return new CommandProcessingResultBuilder().withClientId(clientId).withEntityId(requestId).build();
}
@Override
@Transactional
public CommandProcessingResult reject(final Long requestId, final String rejectionReason) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRequestOrThrow(requestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot reject your own risk category change.");
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? "
+ "WHERE id = ?",
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, requestId);
return new CommandProcessingResultBuilder().withEntityId(requestId).build();
}
@Override
public List<Map<String, Object>> retrieveAll(final String status) {
final StringBuilder sql = new StringBuilder("""
SELECT r.id AS "id",
r.client_id AS "clientId",
c.display_name AS "clientName",
c.account_no AS "cifNumber",
o.name AS "officeName",
r.current_category AS "currentCategory",
r.requested_category AS "requestedCategory",
r.reason AS "reason",
r.status AS "status",
maker.username AS "makerUsername",
to_char(r.created_date, 'YYYY-MM-DD HH24:MI') AS "createdDate",
checker.username AS "checkerUsername",
to_char(r.checker_date, 'YYYY-MM-DD HH24:MI') AS "checkerDate",
r.rejection_reason AS "rejectionReason"
FROM m_client_risk_change_request r
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_office o ON o.id = c.office_id
JOIN m_appuser maker ON maker.id = r.maker_id
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null && !status.isBlank()) {
sql.append(" AND r.status = ? ");
args.add(status);
}
sql.append(" ORDER BY r.id DESC ");
return this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
}
private Map<String, Object> findRequestOrThrow(final Long requestId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_client_risk_change_request WHERE id = ?", requestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Risk category change request not found.");
}
return rows.get(0);
}
/**
* The same checker rule the rest of the AML module uses: a rating change is a compliance
* decision, so it is approved by a manager rather than by whoever happens to be at the screen.
*/
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("principal officer") || n.contains("compliance"));
if (!isChecker) {
throw new IllegalStateException(
"Only a Bank Manager, Compliance Officer or Principal Officer can approve or reject a risk category change.");
}
}
}
@@ -38,6 +38,8 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -513,8 +515,7 @@ public class BranchDayRegisterApiResource {
final Long contraId = contraIds.get(0);
final String currencyCode = jdbcTemplate.queryForObject(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-"
+ System.nanoTime();
final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CASH_MIGRATION);
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
@@ -1056,13 +1057,32 @@ public class BranchDayRegisterApiResource {
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
""", BigDecimal.class, date, officeId));
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
// it was missing from this total entirely, understating expected cash by exactly what a
// walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed
// over that day. face_value + charges + GST is the fee calculation, which can land on a paisa
// fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender
// whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave
// this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and
// are correctly excluded by the payment method filter.
final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0)
FROM m_sponsor_dd_issue_request dd
WHERE dd.status = 'APPROVED'
AND dd.payment_method = 'CASH'
AND dd.issued_date = ?
AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL)
""", BigDecimal.class, date, officeId));
final BigDecimal deposits = savingsDeposits.add(ddCashReceipts);
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
@@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource {
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
// What the counter says this GL credit is actually for — a code value under the
// "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row
// is one of the flows below picks up its own money later (Share Advance Collection, Sponsor
// DD), in which case that adoption checks this tag so it can never take a voucher meant for
// the other one. See 0565_convert_entry_type_to_code_value.xml.
final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
// DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests.
// Persisted so the Sponsor DD issuance screen can read the breakdown back instead of
// retyping face value/charges once this request is approved and selected as cash evidence.
@@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource {
client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource {
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
);
// Globally unique voucher number from the central m_voucher register
@@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource {
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount"
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id"
+ " FROM m_cashier_deposit_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource {
payment_type_name, description, txn_date, status,
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
submitted_at, actioned_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
FROM m_cashier_deposit_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -624,7 +630,12 @@ public class CashierDepositRequestApiResource {
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
} else {
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
// The request's own account_no is normally right, but if a cashier ever searched by a
// short/partial account number this is where that would surface -- prefer the number
// looked up fresh from savingsAccountId, the id actually posted against.
final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId);
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"),
realAccountNo != null ? realAccountNo : accountNo, amount);
}
voucherService.addLines(voucherNo, List.of(debit, credit));
@@ -634,6 +645,19 @@ public class CashierDepositRequestApiResource {
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
/** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space --
* always querying {@code m_savings_account} would silently return an unrelated savings account's
* number whenever the two id spaces happened to collide. Every other non-GL type here is a
* {@code m_savings_account} row. */
private String savingsAccountNoOf(final String accountType, final Long accountId) {
if (accountId == null) {
return null;
}
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
.findFirst().orElse(null);
}
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
@@ -651,7 +675,7 @@ public class CashierDepositRequestApiResource {
txn_date, status, cashier_id, cashier_name, cashier_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
FROM m_cashier_deposit_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -1014,6 +1038,18 @@ public class CashierDepositRequestApiResource {
}
}
/** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
* resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
private Long resolveEntryTypeCvId(final Long provided) {
if (provided != null) {
return provided;
}
return jdbcTemplate.queryForObject(
"SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
+ " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
Long.class);
}
private String buildActivityLog(final String existingJson, final String action,
final String username, final String note) {
JsonArray log = new JsonArray();
@@ -499,86 +499,52 @@ public class CashReportApiResource {
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
// Today's own movement -- what actually crossed the counter, the vault hatch or the
// settlement window on this one date. Unchanged from before: this is informational, "what
// happened today", and stays that way regardless of what the drawer carried in from an
// earlier, unsettled day.
final Map<Long, Map<String, Object>> todayLegs = fetchLegTotals("= CAST(? AS DATE)", txnDate, scopedCashierId);
// Every note and coin that reached the drawer or left it, per denomination.
//
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
// counted against. So the vault allocation and the settlement back are included on the same
// footing as the counter work, and each row says where its notes came from.
//
// Cashier scoping differs per leg because each table records the party differently: the
// counter tables name the acting user, the cash tables name the cashier row.
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
// What the drawer already held before today started -- everything ever recorded up to (but
// not including) this date, net. A cashier who force-closed yesterday without settling, then
// settled today, hands back notes that arrived on several different days; comparing that
// settlement only against today's own vault-in/deposit legs manufactured an impossible
// negative count for every denomination the earlier days had contributed. Cumulative "as of
// yesterday" is exactly the figure VaultPositionService/buildPositionForCashier already use
// for a drawer's running position, applied here as this report's opening balance.
final Map<Long, Map<String, Object>> openingLegs = fetchLegTotals("< CAST(? AS DATE)", txnDate, scopedCashierId);
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
// Counter work: what customers handed over and were paid.
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
// Drawn from the vault in the morning.
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
// Handed back to the vault at close.
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
final Map<Long, Map<String, Object>> merged = new LinkedHashMap<>(todayLegs);
for (final Long denomId : openingLegs.keySet()) {
merged.computeIfAbsent(denomId, id -> emptyLegRow(openingLegs.get(id)));
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
final long vaultInCount = rs.getLong("vault_in_count");
final long depositCount = rs.getLong("deposit_count");
final long withdrawalCount = rs.getLong("withdrawal_count");
final long settledOutCount = rs.getLong("settled_out_count");
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> today : merged.values()) {
final Long denomId = (Long) today.get("denominationId");
final Map<String, Object> opening = openingLegs.getOrDefault(denomId, null);
final long openingCount = opening == null ? 0L
: (long) opening.get("vaultInCount") + (long) opening.get("depositCount")
- (long) opening.get("withdrawalCount") - (long) opening.get("settledOutCount");
final BigDecimal openingAmount = opening == null ? BigDecimal.ZERO
: ((BigDecimal) opening.get("vaultInAmount")).add((BigDecimal) opening.get("depositAmount"))
.subtract((BigDecimal) opening.get("withdrawalAmount")).subtract((BigDecimal) opening.get("settledOutAmount"));
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
final long vaultInCount = (long) today.get("vaultInCount");
final long depositCount = (long) today.get("depositCount");
final long withdrawalCount = (long) today.get("withdrawalCount");
final long settledOutCount = (long) today.get("settledOutCount");
final BigDecimal vaultInAmount = (BigDecimal) today.get("vaultInAmount");
final BigDecimal depositAmount = (BigDecimal) today.get("depositAmount");
final BigDecimal withdrawalAmount = (BigDecimal) today.get("withdrawalAmount");
final BigDecimal settledOutAmount = (BigDecimal) today.get("settledOutAmount");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", denomId);
row.put("denominationValue", today.get("denominationValue"));
row.put("kind", today.get("kind"));
row.put("openingCount", openingCount);
row.put("openingAmount", openingAmount);
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount);
@@ -587,13 +553,18 @@ public class CashReportApiResource {
row.put("withdrawalAmount", withdrawalAmount);
row.put("settledOutCount", settledOutCount);
row.put("settledOutAmount", settledOutAmount);
// What is left of this denomination: everything that came in, less everything that went
// out. This is the figure a teller counts their notes against, per note.
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount",
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
return row;
}, args.toArray());
// What is left of this denomination: whatever the drawer already held, plus what came in
// today, less what went out today. This is the figure a teller counts their notes
// against, per note.
row.put("netCount", openingCount + vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount", openingAmount.add(vaultInAmount).add(depositAmount).subtract(withdrawalAmount)
.subtract(settledOutAmount));
rows.add(row);
}
rows.sort((a, b) -> {
final int byValue = ((Integer) b.get("denominationValue")).compareTo((Integer) a.get("denominationValue"));
return byValue != 0 ? byValue : String.valueOf(a.get("kind")).compareTo(String.valueOf(b.get("kind")));
});
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -762,6 +733,105 @@ public class CashReportApiResource {
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
/**
* The four legs of {@link #closing} -- counter work, vault-in and settled-out -- summed per
* denomination, with {@code dateComparison} deciding which side of the target date each leg is
* read from ({@code "= CAST(? AS DATE)"} for the day itself, {@code "< CAST(? AS DATE)"} for
* everything before it). One query shape shared by both calls in {@link #closing}, rather than
* two copies that could drift apart.
*/
private Map<Long, Map<String, Object>> fetchLegTotals(final String dateComparison, final String dateBound,
final Long scopedCashierId) {
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date " + dateComparison + " " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date " + dateComparison + " AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date " + dateComparison + " AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final Map<Long, Map<String, Object>> byDenomination = new LinkedHashMap<>();
jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> row = new LinkedHashMap<>();
final Long denomId = rs.getLong("denomination_id");
row.put("denominationId", denomId);
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("vaultInCount", rs.getLong("vault_in_count"));
row.put("vaultInAmount", rs.getBigDecimal("vault_in_amount"));
row.put("depositCount", rs.getLong("deposit_count"));
row.put("depositAmount", rs.getBigDecimal("deposit_amount"));
row.put("withdrawalCount", rs.getLong("withdrawal_count"));
row.put("withdrawalAmount", rs.getBigDecimal("withdrawal_amount"));
row.put("settledOutCount", rs.getLong("settled_out_count"));
row.put("settledOutAmount", rs.getBigDecimal("settled_out_amount"));
byDenomination.put(denomId, row);
return row;
}, args.toArray());
return byDenomination;
}
/** A zero-activity "today" row shaped like {@link #fetchLegTotals}'s own rows, for a denomination
* that carried an opening balance into this date but saw no movement of its own on it -- without
* this, that denomination would vanish from the report instead of showing its carried-forward
* count untouched. */
private Map<String, Object> emptyLegRow(final Map<String, Object> template) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", template.get("denominationId"));
row.put("denominationValue", template.get("denominationValue"));
row.put("kind", template.get("kind"));
row.put("vaultInCount", 0L);
row.put("vaultInAmount", BigDecimal.ZERO);
row.put("depositCount", 0L);
row.put("depositAmount", BigDecimal.ZERO);
row.put("withdrawalCount", 0L);
row.put("withdrawalAmount", BigDecimal.ZERO);
row.put("settledOutCount", 0L);
row.put("settledOutAmount", BigDecimal.ZERO);
return row;
}
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
@@ -24,6 +24,8 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -150,7 +152,7 @@ public class ShareAdvanceCollectionApiResource {
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHADV-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE);
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
@@ -186,11 +188,25 @@ public class ShareAdvanceCollectionApiResource {
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
@Transactional
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
// A voucher explicitly tagged Share Collection at save time no longer waits for the counter
// to search it up by hand -- it's pulled onto this list itself, the first time anyone asks
// for it. Anything not tagged this way (an older row, or one deliberately left General) still
// needs the by-voucher search below, same as before.
autoAdoptShareCollectionTagged(clientId);
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
+ " cl.display_name AS client_name, "
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
// The GL account this specific receipt's money actually sits in -- its own posting's
// credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL
// Account picker above can be kept in sync with whichever receipt gets ticked.
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
@@ -231,7 +247,8 @@ public class ShareAdvanceCollectionApiResource {
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
+ " c.applied_share_account_id "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
@@ -271,19 +288,42 @@ public class ShareAdvanceCollectionApiResource {
// GET — GL accounts selectable for a by-voucher lookup
// ══════════════════════════════════════════════════════════════════════
/**
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
* The GL account picker on the Apply Collection screen — narrowed to accounts that actually have
* an APPROVED, not-yet-consumed cashier deposit or clerk collection sitting in them right now,
* so the counter is choosing from real, live money rather than the entire chart of accounts
* (which is every postable GL account in the bank, share-related or not, well over a hundred of
* them, Cash at Vault included, with nothing to say which one has anything to find).
*
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
* applying a receipt picks the account the deposit actually went to, every time.
* Still never resolved from a share product's own Share Suspense mapping -- that mapping is
* exactly what this lookup no longer trusts, since it can go missing or wrong without anyone
* noticing until a voucher search fails at the counter. This filters by what genuinely sits in
* each account today, not by what any product's configuration claims should. Falls back to the
* unfiltered full list on the rare chance nothing currently qualifies, so the picker is never a
* dead end -- a voucher search should not become unreachable just because this narrowing has
* nothing to narrow to yet.
*/
@GET
@Path("/glaccounts")
public Response glAccounts() {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT DISTINCT ga.id, ga.gl_code, ga.name"
+ " FROM acc_gl_account ga"
+ " WHERE ga.disabled = false AND ga.account_usage = 1"
+ " AND ("
+ " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id"
+ " AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))"
+ " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id"
+ " AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no))"
+ " )"
+ " ORDER BY ga.gl_code");
if (rows.isEmpty()) {
rows = jdbcTemplate.queryForList(
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
@@ -316,6 +356,47 @@ public class ShareAdvanceCollectionApiResource {
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — The actual Dr/Cr GL legs a receipt posted, for the details popup
// ══════════════════════════════════════════════════════════════════════
/**
* What this receipt actually did to the ledger — the real Dr/Cr legs under its own
* {@code gl_transaction_id}, not the GL account currently picked in the by-voucher search box
* (a different, unrelated piece of screen state that a details popup must never be shown
* against, since it can name a completely different account than the one this receipt sits in).
*/
@GET
@Path("/{collectionId}/gl-entries")
public Response glEntries(@PathParam("collectionId") final Long collectionId) {
this.context.authenticatedUser();
final List<Map<String, Object>> collRows = jdbcTemplate
.queryForList("SELECT gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId);
if (collRows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", "Receipt " + collectionId + " not found.");
}
final String glTransactionId = (String) collRows.get(0).get("gl_transaction_id");
final List<Map<String, Object>> legs = glTransactionId == null ? List.of()
: jdbcTemplate.queryForList(
"SELECT ga.gl_code, ga.name AS account_name, je.type_enum, je.amount"
+ " FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = ? AND je.reversed = false ORDER BY je.type_enum DESC, je.id",
glTransactionId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("transactionId", glTransactionId);
final List<Map<String, Object>> entries = new java.util.ArrayList<>();
for (final Map<String, Object> leg : legs) {
final Map<String, Object> e = new LinkedHashMap<>();
e.put("glCode", leg.get("gl_code"));
e.put("accountName", leg.get("account_name"));
e.put("drCr", toInt(leg.get("type_enum")) == DEBIT ? "Dr" : "Cr");
e.put("amount", leg.get("amount"));
entries.add(e);
}
out.put("entries", entries);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@@ -472,14 +553,26 @@ public class ShareAdvanceCollectionApiResource {
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
final String txnId = "SHAPP-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE_APPLIED);
final String accountNo = (String) acct.get("account_no");
// Dr Sundry for the share-side receipts, including any over-payment on them — the liability
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
// it was never in Sundry, so nothing here should debit Sundry for it.
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
// Dr whichever account each receipt's money is actually sitting in, receipt by receipt,
// including any over-payment on them — the liability those receipts created is cleared in
// full. A verified fee receipt is deliberately excluded: its money was never held for the
// shares, so nothing here should move it.
//
// Read back from the collection's own posting rather than resolved from the product's Sundry
// mapping. The cashier does not always deposit into Sundry -- an entry fee goes straight to
// its income account, and instalments taken on different days can land in different accounts
// -- so debiting the mapped Sundry account for every receipt took money out of an account
// that had never received it, and left the account that had holding the balance for ever.
// Debiting each receipt's own account is also what the clerk sees: the GL picked against the
// voucher on the apply screen is the account being cleared here.
for (final Map<String, Object> row : collections) {
final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo);
}
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
@@ -595,6 +688,108 @@ public class ShareAdvanceCollectionApiResource {
* missing product mapping can never make a genuine voucher unreachable.
* @return the newly created collection row, or null when no approved deposit matches.
*/
/**
* Pulls every APPROVED, not-yet-consumed deposit or clerk collection explicitly tagged "Share
* Collection" into m_share_advance_collection, so it shows up on the Apply Share / Additional
* Share screen on its own -- the point of tagging a voucher this way at save time in the first
* place.
*
* A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own --
* buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of
* these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER
* auto-adopted, for any client, under any circumstances -- attributing it to "whichever member's
* screen happened to be open when this ran" is a coincidence of timing, not evidence of whose
* money it is, and would silently hand one member's cash to a completely unrelated member simply
* because their Apply Share screen loaded first. A clientless receipt can only ever be claimed
* through the manual by-voucher search, where a person at the counter is actively vouching for it
* against a member they can see, by quoting the specific voucher number the member is holding --
* a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id
* (a Clerk Collection, whose client comes from the savings account it actually debited -- real
* evidence, not a guess) is still only adopted against a matching client_id, never a different
* member than the one asking.
*/
private void autoAdoptShareCollectionTagged(final Long clientIdFilter) {
final List<Map<String, Object>> deposits = jdbcTemplate.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id,"
+ " d.cashier_id, d.cashier_name, d.description"
+ " FROM m_cashier_deposit_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
+ " AND d.client_id IS NOT NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
for (final Map<String, Object> dep : deposits) {
final Long resolvedClientId = toLong(dep.get("client_id"));
// Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match
// who is asking -- never adopted for a different member than the one whose screen it is.
if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) {
continue;
}
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
// What the cashier actually typed at save time, when they typed anything -- that is what
// the Apply Share screen's Description column exists to show. Falls back to a plain
// label only for a row nobody described, same as the manual by-voucher adoption below.
final String depDescription = (String) dep.get("description");
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
: "Share collection - Voucher " + dep.get("voucher_no");
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, dep.get("voucher_no"), resolvedClientId, toLong(dep.get("office_id")),
java.sql.Date.valueOf(collectionDate), dep.get("amount"), dep.get("currency_code"), dep.get("txn_id"),
toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
}
final List<Map<String, Object>> collections = jdbcTemplate.queryForList(
"SELECT d.id, d.voucher_no, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
+ " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id"
+ " FROM m_clerk_disbursement_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
for (final Map<String, Object> req : collections) {
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
continue;
}
final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) {
continue;
}
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId);
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
final Object actionedAt = req.get("actioned_at");
final Object submittedAt = req.get("submitted_at");
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String voucherNo = (String) req.get("voucher_no");
final String receiptNo = nextReceiptNo();
final String reqDescription = (String) req.get("description");
final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
: "Share collection - Voucher " + voucherNo;
// The real acc_gl_journal_entry.transaction_id this approval posted (see
// 0566_add_gl_transaction_id_to_clerk_disbursement_request.xml) -- falls back to the
// voucher number only for a row approved before that column existed, since something has
// to go in a NOT NULL-shaped identity column and the voucher number at least resolves
// back to this same row.
final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, memberClientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency,
glTxnId, toLong(req.get("clerk_id")), req.get("clerk_name"), note);
}
}
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
@@ -604,8 +799,10 @@ public class ShareAdvanceCollectionApiResource {
final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id,"
+ " cv.code_value AS entry_type"
+ " FROM m_cashier_deposit_request d"
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, shareSundryAccountNo);
@@ -618,6 +815,23 @@ public class ShareAdvanceCollectionApiResource {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// The counter tags what a GL receipt was actually collected for at save time, from the
// GLEntryType code (Admin > System > Manage Codes), precisely so a manager here can never
// pick up money that was really meant for something else (a Demand Draft, say) just because
// it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows
// from before this tag existed) are still adoptable -- only an explicit, different purpose
// blocks it.
final String depositEntryType = (String) dep.get("entry_type");
if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. "
+ "Choose a different voucher, or ask the cashier to correct the entry type.");
}
if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?",
toLong(dep.get("id"))).isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been used to issue a Demand Draft.");
}
// Prefer the member recorded on the deposit; fall back to the account being settled.
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
@@ -643,7 +857,7 @@ public class ShareAdvanceCollectionApiResource {
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
@@ -665,16 +879,27 @@ public class ShareAdvanceCollectionApiResource {
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
final String glAccountNo = glAccountNoOverride.trim();
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request"
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
+ " ORDER BY id DESC",
"SELECT d.id, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
+ " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id, cv.code_value AS entry_type"
+ " FROM m_clerk_disbursement_request d"
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE d.voucher_no = ? AND TRIM(d.gl_account_no) = ? AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ " ORDER BY d.id DESC",
voucherNo, glAccountNo);
if (requests.isEmpty()) {
return null;
}
final Map<String, Object> req = requests.get(0);
// Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL
// account the counter picked by hand, so it carries the identical ambiguity.
final String reqEntryType = (String) req.get("entry_type");
if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. "
+ "Choose a different voucher, or ask the clerk to correct the entry type.");
}
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
@@ -692,16 +917,20 @@ public class ShareAdvanceCollectionApiResource {
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
final String reqDescription = (String) req.get("description");
final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
: "Adopted from clerk disbursement voucher " + voucherNo;
final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo,
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, glTxnId,
toLong(req.get("clerk_id")), req.get("clerk_name"), note);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
@@ -809,6 +1038,37 @@ public class ShareAdvanceCollectionApiResource {
}
}
/**
* The account a receipt's money is actually sitting in, read back from the posting that put it
* there — the credit leg of the collection's own journal transaction.
*
* <p>
* Whoever took the money decided where it went: this register's own collection credits Sundry, a
* cashier deposit credits whichever account the cashier posted against, and an entry fee usually
* goes straight to fee income. Reading the account back is therefore the only way to know it, and
* it matches what the clerk picks against the voucher on the apply screen.
*
* <p>
* Falls back to the product's mapped Sundry account for a collection with no posting recorded
* against it, which is where anything taken before this register started stamping its transaction
* id was held.
*
* <p>
* Scoped to the live credit leg, newest first: a transaction id is only as unique as whatever
* minted it, and picking the most recent match keeps this correct even if one is ever reused.
*/
private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
if (glTransactionId == null || glTransactionId.isBlank()) {
return fallbackGlId;
}
final List<Long> rows = jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY id DESC LIMIT 1
""", Long.class, glTransactionId, CREDIT);
return rows.isEmpty() ? fallbackGlId : rows.get(0);
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
@@ -1,439 +0,0 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
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 jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Cashier Share Collection API — POST /v1/cashier/sharecollection
*
* Records cash received from a member for share purchase and posts GL entries:
* Dr SHARES_REFERENCE (Cash at Vault) = shareAmount + chargeAmount
* Cr SHARES_SUSPENSE (Share Sundry) = shareAmount
* Cr INCOME_FROM_FEES = chargeAmount (if > 0)
*
* On account activation Fineract posts:
* Dr SHARES_SUSPENSE → Cr SHARES_EQUITY
* Activation skips charge GL if is_paid_derived = true (set here).
*/
@Path("/v1/cashier/sharecollection")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareCollectionApiResource {
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr)
// Opposite of intuition — verified from Fineract AccountingProcessorHelper.
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
// ── acc_gl_journal_entry.entity_type_enum ─────────────────────────────
// Manual cashier entries use NULL — avoids NPE in PortfolioProductType.fromInt()
// which only handles values 1-5 and returns null for unknown values.
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
// ── m_share_account.status_enum ───────────────────────────────────────
private static final int STATUS_SUBMITTED_PENDING = 100;
// ── Maximum days a collection record is considered valid ──────────────
private static final int MAX_COLLECTION_AGE_DAYS = 30;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// POST — Collect cash for a share account
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response collect(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
// ── Parse request ──────────────────────────────────────────────────
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.request.empty",
"Request body is required.");
}
final JsonObject json;
try {
json = JsonParser.parseString(body).getAsJsonObject();
} catch (Exception e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.request.invalid",
"Request body must be valid JSON.");
}
final Long shareAccountId = getLong(json, "shareAccountId");
final String transactionDateStr = getString(json, "transactionDate");
final BigDecimal shareAmount = getBigDecimal(json, "shareAmount", "shareAmount");
final BigDecimal chargeAmountRaw = getBigDecimal(json, "chargeAmount", "chargeAmount");
final String note = sanitiseNote(getString(json, "note"));
// ── Validate inputs ────────────────────────────────────────────────
if (shareAccountId == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.accountid.required",
"shareAccountId is required.");
}
if (shareAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.shareamount.negative",
"shareAmount cannot be negative.");
}
final BigDecimal safeCharge = chargeAmountRaw.compareTo(BigDecimal.ZERO) > 0
? chargeAmountRaw : BigDecimal.ZERO;
final BigDecimal totalAmount = shareAmount.add(safeCharge);
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.total.zero",
"Total collection amount must be greater than zero.");
}
final LocalDate entryDate;
try {
entryDate = transactionDateStr != null
? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.date.invalid",
"transactionDate must be in YYYY-MM-DD format. Received: " + transactionDateStr);
}
// ── Load account + client details ──────────────────────────────────
final Map<String, Object> acct = loadAccount(shareAccountId);
// ── Account must be in 'Submitted and pending approval' ───────────
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_SUBMITTED_PENDING) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.account.invalid.status",
"Cash collection is only allowed for accounts in 'Submitted and pending approval' "
+ "status. Account " + acct.get("account_no") + " has status code " + statusEnum + ".");
}
// ── Prevent double collection ──────────────────────────────────────
if (Boolean.TRUE.equals(acct.get("cashier_collected"))) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.already.collected",
"Cash has already been collected for share account "
+ acct.get("account_no") + ". Re-collection is not allowed.");
}
final Long officeId = toLong(acct.get("office_id"));
final Long productId = toLong(acct.get("product_id"));
final String currencyCode = (String) acct.get("currency_code");
final String clientName = (String) acct.get("client_name");
final String accountNo = (String) acct.get("account_no");
// ── Resolve GL accounts from product mapping ───────────────────────
final Long glReference = resolveGl(productId, FA_SHARES_REFERENCE);
final Long glSuspense = resolveGl(productId, FA_SHARES_SUSPENSE);
final Long glIncome = resolveGl(productId, FA_INCOME_FROM_FEES);
if (glReference == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.gl.reference.notmapped",
"Share Reference (Cash at Vault) GL is not mapped for product " + productId
+ ". Configure Share Product accounting mappings first.");
}
if (glSuspense == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.gl.suspense.notmapped",
"Share Suspense GL is not mapped for product " + productId
+ ". Configure Share Product accounting mappings first.");
}
if (safeCharge.compareTo(BigDecimal.ZERO) > 0 && glIncome == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.gl.income.notmapped",
"Income from Fees GL is not mapped for product " + productId
+ " but charge amount " + safeCharge + " is present. "
+ "Configure Share Product accounting mappings first.");
}
// ── Generate unique transaction ID ─────────────────────────────────
final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis();
final long userId = currentUser.getId();
final String collectorName = currentUser.getUsername();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
// ── Post GL journal entries ────────────────────────────────────────
// Dr Cash at Vault (SHARES_REFERENCE) = totalAmount
insertEntry(officeId, glReference, DEBIT, totalAmount, txnId,
entryDate, shareAccountId, userId, now, currencyCode,
"Share collection received for " + accountNo
+ (note != null ? " | " + note : ""));
// Cr Share Sundry (SHARES_SUSPENSE) = shareAmount
insertEntry(officeId, glSuspense, CREDIT, shareAmount, txnId,
entryDate, shareAccountId, userId, now, currencyCode,
"Share amount held in suspense - " + accountNo);
// Cr Income from Fees = chargeAmount (only if > 0)
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
insertEntry(officeId, glIncome, CREDIT, safeCharge, txnId,
entryDate, shareAccountId, userId, now, currencyCode,
"Charge collected upfront for " + accountNo);
// Mark charges paid → activation will skip duplicate charge GL entry
jdbcTemplate.update(
"UPDATE m_share_account_charge "
+ "SET is_paid_derived = true, "
+ " amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 "
+ "WHERE account_id = ? AND is_active = true AND is_paid_derived = false",
shareAccountId);
}
// ── Flip cashier_collected flag ────────────────────────────────────
jdbcTemplate.update(
"UPDATE m_share_account SET cashier_collected = true WHERE id = ?",
shareAccountId);
// ── Insert audit / validation record ──────────────────────────────
jdbcTemplate.update(
"INSERT INTO m_share_cashier_collection "
+ "(share_account_id, collected_by_user, collected_by_name, collection_date, "
+ " share_amount, charge_amount, total_amount, gl_transaction_id, "
+ " office_id, currency_code, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
shareAccountId, userId, collectorName,
java.sql.Date.valueOf(entryDate),
shareAmount, safeCharge, totalAmount,
txnId, officeId, currencyCode, note);
// ── Build GL entries summary for response ──────────────────────────
final List<Map<String, Object>> glEntries = new ArrayList<>();
glEntries.add(glEntryMap(resolveGlName(glReference), "Dr", totalAmount, currencyCode));
glEntries.add(glEntryMap(resolveGlName(glSuspense), "Cr", shareAmount, currencyCode));
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
glEntries.add(glEntryMap(resolveGlName(glIncome), "Cr", safeCharge, currencyCode));
}
// ── Response ───────────────────────────────────────────────────────
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("transactionId", txnId);
resp.put("shareAccountId", shareAccountId);
resp.put("accountNo", accountNo);
resp.put("clientName", clientName);
resp.put("collectionDate", entryDate.toString());
resp.put("collectedBy", collectorName);
resp.put("shareAmount", shareAmount);
resp.put("chargeAmount", safeCharge);
resp.put("totalAmount", totalAmount);
resp.put("currencyCode", currencyCode);
resp.put("glEntries", glEntries);
return Response.ok(gson.toJson(resp)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — Check collection status for a share account
// ══════════════════════════════════════════════════════════════════════
@GET
@Path("/{shareAccountId}")
public Response getStatus(@PathParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
try {
final Map<String, Object> rec = jdbcTemplate.queryForMap(
"SELECT scc.share_account_id, sa.account_no, "
+ " mc.display_name AS client_name, "
+ " scc.collected_by_name, scc.collection_date, "
+ " scc.share_amount, scc.charge_amount, scc.total_amount, "
+ " scc.gl_transaction_id, scc.currency_code, scc.note "
+ "FROM m_share_cashier_collection scc "
+ "JOIN m_share_account sa ON sa.id = scc.share_account_id "
+ "JOIN m_client mc ON mc.id = sa.client_id "
+ "WHERE scc.share_account_id = ?",
shareAccountId);
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("collected", true);
resp.put("shareAccountId", shareAccountId);
resp.put("accountNo", rec.get("account_no"));
resp.put("clientName", rec.get("client_name"));
resp.put("collectedBy", rec.get("collected_by_name"));
resp.put("collectionDate", rec.get("collection_date") != null
? rec.get("collection_date").toString() : null);
resp.put("shareAmount", rec.get("share_amount"));
resp.put("chargeAmount", rec.get("charge_amount"));
resp.put("totalAmount", rec.get("total_amount"));
resp.put("transactionId", rec.get("gl_transaction_id"));
resp.put("currencyCode", rec.get("currency_code"));
resp.put("note", rec.get("note"));
return Response.ok(gson.toJson(resp)).build();
} catch (EmptyResultDataAccessException e) {
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("collected", false);
resp.put("shareAccountId", shareAccountId);
return Response.ok(gson.toJson(resp)).build();
}
}
// ══════════════════════════════════════════════════════════════════════
// Private helpers
// ══════════════════════════════════════════════════════════════════════
private Map<String, Object> loadAccount(final Long shareAccountId) {
try {
return jdbcTemplate.queryForMap(
"SELECT sa.id, sa.account_no, sa.status_enum, sa.product_id, "
+ " sa.cashier_collected, sa.currency_code, "
+ " mc.office_id, mc.display_name AS client_name "
+ "FROM m_share_account sa "
+ "JOIN m_client mc ON mc.id = sa.client_id "
+ "WHERE sa.id = ?",
shareAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.account.notfound",
"Share account with id " + shareAccountId + " does not exist.");
}
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ "WHERE product_id = ? AND product_type = ? AND financial_account_type = ?",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (IncorrectResultSizeDataAccessException e) {
// covers both EmptyResultDataAccessException (0 rows) and multiple rows
return null;
}
}
private String resolveGlName(final Long glAccountId) {
if (glAccountId == null) return "Unknown GL";
try {
return jdbcTemplate.queryForObject(
"SELECT name FROM acc_gl_account WHERE id = ?",
String.class, glAccountId);
} catch (EmptyResultDataAccessException e) {
return "GL-" + glAccountId;
}
}
private void insertEntry(final Long officeId, final Long glAccountId,
final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId,
final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry "
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, "
+ " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId,
java.sql.Date.valueOf(entryDate), typeEnum, amount, description,
shareAccountId, // entity_id — links entry to the share account
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
toUtc(now),
toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private Map<String, Object> glEntryMap(final String account, final String type,
final BigDecimal amount, final String currency) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("account", account);
m.put("type", type);
m.put("amount", amount);
m.put("currency", currency);
return m;
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) return null;
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number ? ((Number) o).intValue() : 0;
}
private Long getLong(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
}
private BigDecimal getBigDecimal(final JsonObject json, final String key,
final String fieldLabel) {
if (!json.has(key) || json.get(key).isJsonNull()) return BigDecimal.ZERO;
try {
return json.get(key).getAsBigDecimal();
} catch (NumberFormatException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.field.invalid.number",
"Field '" + fieldLabel + "' must be a valid number.");
}
}
private String getString(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
}
@@ -389,8 +389,12 @@ public class ChequeBookIssuanceChargeService {
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
@@ -192,8 +192,12 @@ public class ClearingJournalService {
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
@@ -116,6 +116,12 @@ public class ClerkDisbursementRequestApiResource {
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
requestType = "DISBURSEMENT";
}
// What this GL leg is actually for — a code value under "GLEntryType" (Admin > System >
// Manage Codes). A COLLECTION credits a GL account the counter picks by hand, the same
// ambiguity a cashier Receipt has (see CashierDepositRequestApiResource and
// 0565_convert_entry_type_to_code_value.xml). ShareAdvanceCollectionApiResource checks this
// before adopting one of these rows as a share collection.
final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
throw apiError(400, "At least one entry is required");
@@ -145,6 +151,24 @@ public class ClerkDisbursementRequestApiResource {
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
throw apiError(400, "Each entry must have an account number");
}
// Whatever the counter searched with to find this account (often a short suffix, not the
// full number) is not what belongs on this record permanently — replace it here, once, at
// submit time, with the real number looked up from the id the search actually resolved.
// Leaves the entry's own accountNo untouched only when it carries no id to look up from
// (a GL leg's account/glAccountId is set, so this only bites when the search itself never
// matched anything, which submit-time validation elsewhere is already responsible for).
final String entryAccountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final boolean entryIsGl = "GL".equals(entryAccountType);
final String realAccountNo = entryIsGl
? (entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? glCodeOf(entry.get("glAccountId").getAsLong())
: null)
: (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
? nonGlAccountNoOf(entryAccountType, entry.get("savingsAccountId").getAsLong())
: null);
if (realAccountNo != null) {
entry.addProperty("accountNo", realAccountNo);
}
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
// working, since nothing about a restricted customer should prevent them being paid.
@@ -180,21 +204,27 @@ public class ClerkDisbursementRequestApiResource {
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
// Same correction as each entry above, for the request's own top-level GL — and the entries
// are stored from the mutated array (each accountNo now the real one), not the original
// as-submitted JSON string.
final String realGlAccountNo = Optional.ofNullable(glCodeOf(glAccountId)).orElse(glAccountNo);
final String correctedCreditEntriesJson = gson.toJson(creditEntries);
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_disbursement_request
(gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code,
credit_entries, payment_type_id, payment_type_name, description,
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type, entry_type_cv_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
glAccountId, glAccountNo, glAccountName, txnAmount,
glAccountId, realGlAccountNo, glAccountName, txnAmount,
officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode,
creditEntriesJson, paymentTypeId, paymentTypeName, description,
correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
activityLog, Timestamp.from(Instant.now()), requestType
activityLog, Timestamp.from(Instant.now()), requestType, entryTypeCvId
);
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
@@ -282,7 +312,7 @@ public class ClerkDisbursementRequestApiResource {
+ " credit_entries, payment_type_name, description,"
+ " clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username,"
+ " rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ " rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id"
+ " FROM m_clerk_disbursement_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -349,7 +379,7 @@ public class ClerkDisbursementRequestApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount,"
+ " credit_entries, payment_type_name, description, clerk_name,"
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id"
+ " FROM m_clerk_disbursement_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -473,6 +503,11 @@ public class ClerkDisbursementRequestApiResource {
final List<String> withdrawalSavingsTxnRefs = new ArrayList<>();
BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO;
Long collectionTargetGlId = null;
// The real acc_gl_journal_entry.transaction_id this approval posts -- captured here because
// nothing else on the row remembers it otherwise (txn_refs only holds the account-side
// transaction ids). Whoever needs the actual GL posting afterwards (Share Advance Collection
// adopting a tagged voucher, a GL detail view) reads it back from gl_transaction_id.
String approvedGlTxnId = null;
// Savings-control GL data per account for the direct combined journal entry.
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
@@ -538,6 +573,7 @@ public class ClerkDisbursementRequestApiResource {
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
}
approvedGlTxnId = btGlTxnId;
// Audit log in m_bank_transfer_log
final String btTxnRefsStr = String.join(",", txnRefs);
@@ -683,6 +719,7 @@ public class ClerkDisbursementRequestApiResource {
if (disbGlTxnId != null) {
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
}
approvedGlTxnId = disbGlTxnId;
}
// ── Collection GL entry ───────────────────────────────────────────────
@@ -705,6 +742,7 @@ public class ClerkDisbursementRequestApiResource {
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
approvedGlTxnId = collGlTxnId;
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
@@ -756,11 +794,11 @@ public class ClerkDisbursementRequestApiResource {
jdbcTemplate.update("""
UPDATE m_clerk_disbursement_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
txn_refs = ?, activity_log = ?, actioned_at = ?
txn_refs = ?, gl_transaction_id = ?, activity_log = ?, actioned_at = ?
WHERE id = ?
""",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id);
txnRefsStr, approvedGlTxnId, updatedLog, Timestamp.from(Instant.now()), id);
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -1107,6 +1145,34 @@ public class ClerkDisbursementRequestApiResource {
* A failure here does not fail the approval: every transaction is posted by this point, and the
* money has moved. The problem is logged, and the voucher keeps its number without sides.
*/
/** The GL account's own code, looked up fresh from its id -- never trust a caller-supplied string
* for what belongs on a voucher's GL-side line. Null-safe so a possibly-missing id passes through. */
private String glCodeOf(final Long glAccountId) {
if (glAccountId == null) {
return null;
}
return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
.findFirst().orElse(null);
}
/** The account's own account number, looked up fresh from its id -- same reasoning as
* {@link #glCodeOf}, for the non-GL side of a voucher line. The id travels in this entry's
* {@code savingsAccountId} field regardless of what kind of account it actually names (this
* request's JSON has no separate field per account type), but "CURRENT" accounts live in their
* own {@code m_current_account} table with their own id space -- looking every non-GL id up
* against {@code m_savings_account} would silently return an unrelated savings account's number
* whenever the two id spaces happened to collide, which is worse than the truncated-search-string
* bug this method exists to fix. Every other non-GL type (savings, fixed deposit, recurring
* deposit) is a {@code m_savings_account} row distinguished only by {@code deposit_type_enum}. */
private String nonGlAccountNoOf(final String accountType, final Long accountId) {
if (accountId == null) {
return null;
}
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
.findFirst().orElse(null);
}
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
@@ -1124,12 +1190,28 @@ public class ClerkDisbursementRequestApiResource {
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final boolean isGlEntry = "GL".equals(accountType);
// For a Disbursement/Collection, the society's own GL side is added once as a single
// total further down (from sourceGlId, the actually-resolved id) -- not read per entry
// here. A Collection's payload names its GL leg inside creditEntries too (so the
// amounts balance for the request's own DR/CR validation at submit time), but if this
// loop also turned that same entry into a line, the GL account would be posted twice on
// one voucher: once here (wrongly signed, since entryIsDebit below ignores this entry's
// own dbCr and applies isCollection to every entry in the array) and again as the
// society's side, with counterpartyTotal inflated by the GL entry's own amount on top of
// it. A bank transfer has no separate society side and every entry, GL or not, is a real
// leg of the transfer, so nothing is skipped there.
if (!isBankTransfer && isGlEntry) {
continue;
}
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
final String enteredAccountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
? entry.get("accountName").getAsString()
: accountNo;
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
: enteredAccountNo;
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? entry.get("glAccountId").getAsLong()
@@ -1138,6 +1220,14 @@ public class ClerkDisbursementRequestApiResource {
? entry.get("savingsAccountId").getAsLong()
: null;
// Whatever the counter searched with to find this account (often a short suffix, not
// the full number) is never what belongs on the slip. The id is what was actually
// resolved and posted against, so look its real number up from the database rather
// than trust the string the client happened to submit alongside it -- the id is the
// only part of this entry that can't be typo'd or truncated.
final String accountNo = isGlEntry ? Optional.ofNullable(glCodeOf(glId)).orElse(enteredAccountNo)
: Optional.ofNullable(nonGlAccountNoOf(accountType, savingsId)).orElse(enteredAccountNo);
// A bank transfer states each side on the entry itself; the other two directions
// put every counterparty on the side opposite the society's GL.
final boolean entryIsDebit = isBankTransfer
@@ -1163,7 +1253,10 @@ public class ClerkDisbursementRequestApiResource {
// between them — so its entries already balance on their own.
if (!isBankTransfer) {
final String glName = (String) record.get("gl_account_name");
final String glNo = (String) record.get("gl_account_no");
// Same reasoning as above: gl_account_no is whatever string was stored on this request
// at submit time, not necessarily this GL account's real code. sourceGlId is what was
// actually resolved and posted against.
final String glNo = Optional.ofNullable(glCodeOf(sourceGlId)).orElse((String) record.get("gl_account_no"));
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
}
@@ -1183,7 +1276,7 @@ public class ClerkDisbursementRequestApiResource {
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
description, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason,
txn_refs, error_message, activity_log, submitted_at, actioned_at
txn_refs, error_message, activity_log, submitted_at, actioned_at, entry_type_cv_id
FROM m_clerk_disbursement_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -1242,6 +1335,18 @@ public class ClerkDisbursementRequestApiResource {
try { return new BigDecimal(raw); } catch (NumberFormatException e) { return null; }
}
/** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
* resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
private Long resolveEntryTypeCvId(final Long provided) {
if (provided != null) {
return provided;
}
return jdbcTemplate.queryForObject(
"SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
+ " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
Long.class);
}
private String buildActivityLog(final String existingJson, final String action,
final String username, final String note) {
JsonArray log = new JsonArray();
@@ -1321,9 +1426,12 @@ public class ClerkDisbursementRequestApiResource {
private Long findCashVaultGlId(final List<String> txnRefs, final int typeEnum) {
for (final String txnRef : txnRefs) {
// ORDER BY id DESC: if transaction_id text is ever reused (e.g. after a sequence
// reset), always prefer the most recently posted matching row over a stale one.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
+ " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1",
"S" + txnRef, typeEnum);
if (!rows.isEmpty()) {
return ((Number) rows.get(0).get("account_id")).longValue();
@@ -1402,9 +1510,12 @@ public class ClerkDisbursementRequestApiResource {
* typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault).
*/
private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text,
// in case the id is ever reused (e.g. after a sequence reset).
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
+ " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1",
fullTxnId, typeEnum);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
}
@@ -1417,8 +1528,11 @@ public class ClerkDisbursementRequestApiResource {
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
// Guarded by reversed = false: if transaction_id text is ever reused, this can
// only flip rows that are currently live, never re-touch an already-reversed
// unrelated transaction's rows.
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
e.txnId);
}
}
@@ -1429,7 +1543,7 @@ public class ClerkDisbursementRequestApiResource {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true",
e.txnId);
}
}
@@ -1442,9 +1556,15 @@ public class ClerkDisbursementRequestApiResource {
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) {
// Take only the most recently posted `entries.size()` rows for this transaction_id text,
// then restore ascending id order. If the id text is ever reused (e.g. after a sequence
// reset), this keeps the positional zip below scoped to the rows this call itself just
// posted, instead of silently pulling in an older, unrelated combined entry's rows.
final List<Long> rowIds = jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC",
Long.class, glTxnId, typeEnum);
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT ?",
Long.class, glTxnId, typeEnum, entries.size());
java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
if (savingsAccId != null) {
@@ -50,6 +50,8 @@ import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -797,20 +799,31 @@ public class ClerkClosureRequestApiResource {
}
try {
final Map<String, Object> control = voucherService.savingsControlAccount(savingsAccountId);
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name"))
: (String) control.get("name");
final String accountNo = (String) record.get("account_no");
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
// This is the member's own savings account leg, not a GL posting -- debitGl(controlGlId,
// ...) named the shared Savings Control GL account here, mismatched against the member's
// own account number on the same line (the same bug class as the share-withdrawal voucher).
lines.add(VoucherLine.debitSavings(savingsAccountId, memberSideName, accountNo, closingAmount));
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
if (roundingDirection > 0) {
lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
lines.add(VoucherLine.debitGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
}
lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
// The same Cash at Vault / Cash at Teller account postRoundingAdjustment() actually posts
// to (resolveCashGlAccount()) — named and coded from the ledger rather than the fixed
// "Cash in Hand" / no-code placeholder this used to show, which said nothing when a reader
// wanted to confirm this voucher against the real entry.
final Map<String, Object> cashGlForVoucher = resolveCashGlAccount();
final Long cashGlIdForVoucher = ((Number) cashGlForVoucher.get("id")).longValue();
lines.add(VoucherLine.creditGl(cashGlIdForVoucher, (String) cashGlForVoucher.get("name"), glCodeOf(cashGlIdForVoucher),
cashierDisbursedAmount));
if (roundingDirection < 0) {
lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
lines.add(VoucherLine.creditGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
}
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
@@ -845,7 +858,7 @@ public class ClerkClosureRequestApiResource {
// Same business date as the closure this is adjusting, so the two never land on
// different days in the day book.
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final String transactionId = "CLOSEROUND-" + officeId + "-" + entryDate.toString().replace("-", "") + "-" + System.nanoTime();
final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CLOSURE_ROUNDING);
final Long cashGlId = ((Number) cashGl.get("id")).longValue();
// type_enum: 2 = DEBIT, 1 = CREDIT.
@@ -893,6 +906,28 @@ public class ClerkClosureRequestApiResource {
* Falls back to that code where the product has no mapping yet — every product created before
* the row existed is in that position, and a closure must not fail over a few paise.
*/
/** The GL account's own code, for a voucher's GL-side line — never the account/party this entry
* happens to relate to. Null-safe so a possibly-missing id passes through. */
private String glCodeOf(final Long glAccountId) {
if (glAccountId == null) {
return null;
}
return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
.findFirst().orElse(null);
}
/** The GL account's own configured name, for the party label on a voucher's GL-side line — fixed
* labels like "Round Off Account" stood in for whatever the product/tenant's own chart of
* accounts actually maps the rounding/cash GL to, so a voucher read as posting against a party
* that doesn't exist in the ledger whenever the real mapped account was named differently. */
private String glNameOf(final Long glAccountId) {
if (glAccountId == null) {
return "GL Account";
}
return jdbcTemplate.queryForList("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
.findFirst().orElse("GL Account");
}
private Long resolveRoundOffGlAccount(final Long savingsAccountId) {
if (savingsAccountId != null) {
final List<Long> mapped = jdbcTemplate.queryForList("""
@@ -38,18 +38,23 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.ShareAccountingSuppression;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -64,6 +69,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -72,9 +79,10 @@ import org.springframework.transaction.annotation.Transactional;
* 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).
* rejects it. Settlement is always a SAVINGS book-transfer to the member's linked savings account —
* approval alone is terminal, see {@link #creditLinkedSavingsAccount} — never a cash payout at the
* teller counter (no cash denomination/disbursement leg; a share redemption is a book-entry, not a
* cash handover).
*/
@Path("/v1/clerk/share-withdrawal-requests")
@Component
@@ -100,24 +108,28 @@ public class ClerkShareWithdrawalRequestApiResource {
"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" };
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot", "settlement_mode",
"settlement_gl_account_id" };
// "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
// the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
// pre-settlement_payment_type_id-column legacy request still resolves to a real payment type
// instead of silently posting its settlement with none at all.
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
// ── Sundry pass-through for the redemption settlement ───────────────────
// ── GL posting for the redemption and its settlement ────────────────────
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
private static final int DEBIT = 2;
// m_savings_account.status_enum: 300 = active.
private static final int SAVINGS_STATUS_ACTIVE = 300;
// acc_product_mapping.financial_account_type for share products.
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
private static final int PRODUCT_TYPE_SHARES = 4;
// What Fineract's own bridge prefixes a share transaction's journal entries with
// (AccountingProcessorHelper.SHARE_TRANSACTION_IDENTIFIER). Reused so an entry this class posts
// in the platform's place groups under the id everything else already looks for.
private static final String SHARE_TXN_ID_PREFIX = "SH";
// acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control.
private static final int PRODUCT_TYPE_SAVINGS = 2;
private static final int FA_SAVINGS_CONTROL = 2;
@@ -142,6 +154,16 @@ public class ClerkShareWithdrawalRequestApiResource {
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final String description = fromJsonHelper.extractStringNamed("description", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
// 'SAVINGS' (default, and what every request submitted before this field existed carries)
// settles by book-transfer to the linked savings account; 'GL' settles by book-transfer to
// whichever GL account the clerk picked on the CR row instead (a settlement that never
// touches a member's own account) — see the settlement branch in approve(). 'NONE' is not
// accepted from the client at all; it is what 'GL' downgrades to below when the clerk's own
// chosen GL account turns out to be the redemption's own Sundry account.
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
: settlementModeRaw.trim().toUpperCase();
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", 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
@@ -169,8 +191,31 @@ public class ClerkShareWithdrawalRequestApiResource {
if (meetingDate == null || meetingDate.isBlank()) {
throw apiError(400, "Date of Meeting is required for a share withdrawal");
}
if (!"SAVINGS".equals(settlementMode) && !"GL".equals(settlementMode)) {
throw apiError(400, "Settlement Mode must be 'SAVINGS' or 'GL'");
}
if ("GL".equals(settlementMode)) {
if (settlementGlAccountId == null) {
throw apiError(400, "Settlement GL Account is required for a GL settlement");
}
GLAccountTransactionGuard.assertPostable(jdbcTemplate, settlementGlAccountId);
// A redemption that settles nowhere further credits the product's own Sundry GL --
// picking that same account as the settlement target here would name Sundry as both the
// source and the destination of the one entry, a self-cancelling posting with no real
// movement that no real CBS ledger allows. Rather than reject this
// outright, treat it as what the clerk actually means: "stop here, the value stays in
// Sundry" -- downgrade to 'NONE' so approve() posts nothing further and raises a voucher
// for exactly leg 1 (Dr Share / Cr Sundry) on its own, standalone.
final Long productIdForSundryCheck = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?",
Long.class, shareAccountId);
final Long glSundryForCheck = resolveGl(productIdForSundryCheck, FA_SHARES_SUSPENSE);
if (glSundryForCheck != null && glSundryForCheck.equals(settlementGlAccountId)) {
settlementMode = "NONE";
settlementGlAccountId = null;
}
}
if (settlementPaymentTypeId == null) {
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
throw apiError(400, "Settlement Payment Type is required — pick how the settlement should be credited");
}
validateSettlementPaymentType(settlementPaymentTypeId);
@@ -180,21 +225,23 @@ public class ClerkShareWithdrawalRequestApiResource {
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot,
settlement_mode, settlement_gl_account_id)
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);
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot,
settlementMode, settlementGlAccountId);
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.
* A SAVINGS or GL settlement is always a book-transfer — 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
@@ -400,9 +447,15 @@ public class ClerkShareWithdrawalRequestApiResource {
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)));
// A GL settlement never touches a savings account, so this check is reported as
// not-applicable rather than failed for a GL request — a share account with no linked
// savings is a perfectly valid GL-settlement case.
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
checks.add(glSettlementMode
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
: 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
@@ -478,19 +531,31 @@ public class ClerkShareWithdrawalRequestApiResource {
}
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";
// Only meaningful for a SAVINGS settlement: creditLinkedSavingsAccount() posts through a
// plain savings deposit command, which resolves its GL leg from the savings product's own
// Fund Source mapping -- the exact bug class this check exists to catch. A GL settlement
// never goes near that mapping at all; it credits the clerk's chosen GL account directly,
// so this check is reported not-applicable instead.
if (glSettlementMode) {
final Object glAccountIdObj = record.get("settlement_gl_account_id");
final boolean settlementGlPresent = glAccountIdObj != null;
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
} else {
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));
}
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
if (clientId != null) {
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
@@ -654,7 +719,13 @@ public class ClerkShareWithdrawalRequestApiResource {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The command is run for what it does to the share account -- the holding it reduces and the
// transaction it records -- not for its accounting. Left to itself it would credit the
// product's fixed "Shares Reference" account, which is a stand-in for a counterparty it has
// no way of knowing; this register does know it, because the clerk keyed it, and posts the
// entry itself below against the account the money actually reached.
final CommandProcessingResult result = ShareAccountingSuppression
.withOwnShareEntries(() -> 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.
@@ -662,30 +733,65 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
// Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
// Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
// leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
// "give cash back over the counter" assumption breaks that the moment the settlement below
// lands on a different GL than this leg did.
if (shareTransactionId != null) {
routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
currentUser.getId());
// Settlement: within this SAME approval transaction, the redemption value is booked to
// wherever the clerk said it should go. Where that destination is known here -- a linked
// savings account, or a GL the clerk picked -- the native redeem posting's own Cr Shares
// Reference (Cash at Vault) leg is redirected straight onto it below, so the ledger reads as
// the single entry the clerk actually keyed: Dr Share Capital / Cr <destination>.
final String settlementMode = Optional.ofNullable((String) record.get("settlement_mode")).orElse("SAVINGS");
final Settlement settlement;
final GlSettlement glSettlement;
final String settlementNote;
// Where the redemption credit lands, and what to call it on the journal line. Resolved per
// mode, then applied once below.
final Long destinationGl;
final String destinationNarration;
if ("NONE".equals(settlementMode)) {
// The clerk deliberately chose to settle nowhere further: Share Sundry is the
// destination, not a staging post, and the value genuinely rests there until some later
// entry moves it. raiseSundryOnlyVoucher() below gives that entry its own standalone
// voucher instead of leaving it visible only as "Also posted" under some other voucher.
settlement = null;
glSettlement = null;
final Map<String, Object> noneAcct = loadShareAccountGlContext(shareAccountId);
destinationGl = requireShareGl(toLong(noneAcct.get("product_id")), FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
destinationNarration = "Share redemption held in Sundry pending settlement";
settlementNote = "Redemption booked to Share Sundry — no further settlement leg.";
} else if ("GL".equals(settlementMode)) {
settlement = null;
final Object glAccountIdObj = record.get("settlement_gl_account_id");
final Long settlementGlAccountId = glAccountIdObj instanceof Number ? ((Number) glAccountIdObj).longValue() : null;
glSettlement = resolveGlSettlement(shareTransactionId, settlementGlAccountId);
destinationGl = glSettlement != null ? glSettlement.glAccountId() : null;
destinationNarration = "Share redemption settled to GL account";
settlementNote = glSettlement != null
? "Settled " + glSettlement.amount() + " to GL account " + glCodeOf(glSettlement.glAccountId())
: "Share transaction not found — GL settlement could not be booked.";
} else {
glSettlement = null;
settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber);
destinationGl = settlement != null ? settlement.savingsControlGlId() : null;
destinationNarration = "Share redemption settled to savings";
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.";
}
// Settlement: within this SAME approval transaction, immediately credit the redemption value
// to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
// not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
// land on the same account the leg above credited. See creditLinkedSavingsAccount().
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);
// One posting, not two: the native redeem's Cr Cash at Vault leg is replaced by a credit to
// the destination itself, under the native posting's own transaction_id. The ledger then
// holds exactly the entry the clerk keyed -- Dr Share Capital / Cr <destination> -- instead
// of routing it through Sundry and back out again, which left two transaction groups and a
// pass-through balance that always netted to zero.
//
// Where no destination could be resolved (no linked savings account, or the share
// transaction could not be read back), the native Cr leg is left exactly as Fineract posted
// it rather than being reversed with nothing put in its place, which would leave the Dr
// Share Capital leg standing alone and the entry out of balance.
if (shareTransactionId != null && destinationGl != null) {
postRedemptionEntry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
currentUser.getId(), destinationGl, destinationNarration);
}
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);
@@ -696,7 +802,8 @@ public class ClerkShareWithdrawalRequestApiResource {
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,
Timestamp.from(Instant.now()),
settlement != null ? settlement.amount() : (glSettlement != null ? glSettlement.amount() : null),
settlement != null ? settlement.savingsTransactionId() : null, id);
if (approvedRows == 0) {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
@@ -712,7 +819,9 @@ public class ClerkShareWithdrawalRequestApiResource {
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
// redemption settled into a member's savings account left nothing on the voucher register to
// read it back from. The GL entries were always written — it is the slip that was missing.
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
final String voucherNo = "NONE".equals(settlementMode) ? raiseSundryOnlyVoucher(id, shareTransactionId, currentUser)
: "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareTransactionId, record, glSettlement, currentUser)
: raiseShareWithdrawalVoucher(id, shareAccountId, shareTransactionId, record, settlement, currentUser);
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -723,7 +832,9 @@ public class ClerkShareWithdrawalRequestApiResource {
if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId);
}
if (settlement != null) {
if (glSettlement != null) {
res.addProperty("settlementAmount", glSettlement.amount());
} else if (settlement != null) {
res.addProperty("settlementAmount", settlement.amount());
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
} else {
@@ -754,7 +865,11 @@ public class ClerkShareWithdrawalRequestApiResource {
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);
// Suppressed for the same reason as the redemption above: anything this posts would go
// against the product's fixed "Shares Reference" account. There is nothing left for it to
// post in any case -- this runs only once the holding has reached zero, so the redemption
// that emptied it has already been booked, in full, against the account the money reached.
ShareAccountingSuppression.withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(closeCommand));
return true;
}
@@ -771,32 +886,34 @@ public class ClerkShareWithdrawalRequestApiResource {
* savings credit have all already succeeded in this transaction, and losing the slip is worth
* less than rolling back a member's money.
*/
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map<?, ?> record,
final Settlement settlement, final AppUser currentUser) {
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Long shareTransactionId,
final Map<?, ?> record, final Settlement settlement, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || settlement == null
|| settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
try {
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
shareAccountId);
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
final Long glShare = redeemDebitGlOf(shareTransactionId);
if (glShare == null) {
return null;
}
final Long savingsAccountId = jdbcTemplate
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
.findFirst().orElse(null);
final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
final String savingsAccountNo = savingsAccountId == null ? null
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
.stream().findFirst().orElse(null);
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), settlement.amount()));
// This leg is the member's own savings account receiving the redemption proceeds, not a
// second GL posting -- creditGl(glSavingsControl, ...) named the shared Savings Control GL
// account here, mismatched against the member's own account number on the same line.
lines.add(VoucherLine.creditSavings(savingsAccountId, memberName, savingsAccountNo, settlement.amount()));
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
// savings account, which is the receipt series — the share account is the source, not a
@@ -807,6 +924,14 @@ public class ClerkShareWithdrawalRequestApiResource {
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
settlement.savingsTransactionId());
}
// Every other request type that raises a voucher stores its own voucher_no back on
// itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
// find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
// invisible everywhere a register looks it up by its owning request, understating the
// branch's Payment total and the Day Register's expected cash by exactly this amount.
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
@@ -814,7 +939,177 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
private record Settlement(BigDecimal amount, Long savingsTransactionId, Long savingsControlGlId) {}
private record GlSettlement(BigDecimal amount, Long glAccountId) {}
/**
* The GL account the native redeem command debited — the share capital side of the redemption,
* which is what a voucher's Dr line has to name now that the credit goes straight to its
* destination and no Sundry leg stands between them.
*
* <p>
* Read back from what was actually posted rather than resolved from a mapping constant: this
* class has never posted that leg itself (only Fineract's own redeem command does), so reading it
* back is correct whichever GL the product's Shares Equity mapping happens to point at.
*
* <p>
* Scoped to the live Dr row for this share transaction, newest first: the "SH"+id transaction_id
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
* reset id sequence in a dev environment can violate.
*/
private Long redeemDebitGlOf(final Long shareTransactionId) {
if (shareTransactionId == null) {
return null;
}
return jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE share_transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY id DESC LIMIT 1
""", Long.class, shareTransactionId, DEBIT).stream().findFirst().orElse(null);
}
/**
* Reads back the amount to settle and names the GL account the clerk picked on the CR row of the
* Fund Transfer — the GL counterpart to {@link #creditLinkedSavingsAccount} for a settlement that
* never touches a member's own account (e.g. crediting a "DD Payable" holding account, so a
* second, independent manual entry can later move the value out to the actual demand draft).
*
* <p>
* Nothing is posted here. The caller redirects the native redeem posting's own credit leg onto
* this account, so the redemption reads as the one entry the clerk keyed — Dr Share Capital / Cr
* that GL — rather than as a Sundry hop that nets to zero.
*
* <p>
* Unlike a cash settlement, nothing physical changes hands here, so the amount is not rounded —
* the redemption value posts to the paisa, same as the linked-savings path.
*/
private GlSettlement resolveGlSettlement(final Long shareTransactionId, final Long settlementGlAccountId) {
if (shareTransactionId == null) {
return null;
}
if (settlementGlAccountId == null) {
throw apiError(409, "No settlement GL account was recorded on this request.");
}
final Map<String, Object> row = jdbcTemplate.queryForList("""
SELECT sat.amount AS settlementAmount FROM m_share_account_transactions sat WHERE sat.id = ?
""", shareTransactionId).stream().findFirst().orElse(null);
if (row == null) {
return null;
}
return new GlSettlement((BigDecimal) row.get("settlementAmount"), settlementGlAccountId);
}
/**
* The voucher for an approved GL redemption: Dr Share Capital, Cr the clerk-chosen settlement
* GL account — the same two accounts the redemption actually posted to, both named and coded
* from the ledger since both are real GL accounts here (unlike the linked-savings path, there is
* no member-account leg on this side at all).
*/
private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareTransactionId, final Map<?, ?> record,
final GlSettlement glSettlement, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || glSettlement == null || glSettlement.amount() == null
|| glSettlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
try {
final Long glShare = redeemDebitGlOf(shareTransactionId);
final Long glTarget = glSettlement.glAccountId();
if (glShare == null) {
return null;
}
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), glSettlement.amount()));
lines.add(VoucherLine.creditGl(glTarget, glNameOf(glTarget), glCodeOf(glTarget), glSettlement.amount()));
// PREFIX_DEBIT, not CREDIT: unlike the linked-savings path, this settlement never credits
// a member's own account -- the value is moving out of share capital toward an eventual
// payout (e.g. a second, independent manual entry crediting a "DD Payable" account out to
// the actual draft), which is the payment series, not a receipt.
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
// Every other request type that raises a voucher stores its own voucher_no back on
// itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
// find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
// invisible everywhere a register looks it up by its owning request.
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a GL voucher for share withdrawal request {}", requestId, e);
return null;
}
}
/**
* The voucher for a request that settles nowhere further than leg 1 itself (settlement mode
* 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #postRedemptionEntry} just
* posted, given its own standalone voucher instead of only being visible as "Also posted" under
* some other request's voucher (see {@code VoucherVerificationApiResource.relatedGlEntries()}).
*
* <p>
* Read back from {@code acc_gl_journal_entry} by the native transaction's own transaction_id
* (the same technique {@code relatedGlEntries()} uses) rather than re-resolving the Shares
* Equity GL directly — there is no {@code FA_SHARES_EQUITY} constant in this class because that
* leg has never been posted by this class itself, only by Fineract's own native redeem command;
* reading back what was actually posted is correct regardless of which GL that turns out to be.
*
* <p>
* Capped to the 2 most recently inserted matching rows (highest id), not every row under that
* transaction_id: {@code "SH" + shareTransactionId} is only unique so long as
* {@code m_share_account_transactions.id} never repeats, which a reset/reseeded id sequence in a
* dev/test environment can violate — an older, unrelated redemption that happened to land on the
* same id would otherwise be pulled in as a second, phantom copy of this leg, doubling the total
* shown here even though only one pair was ever posted for this approval. This call runs
* immediately after {@link #postRedemptionEntry} in the same approve(), so the highest-id pair
* is always the one it just inserted.
*/
private String raiseSundryOnlyVoucher(final Long requestId, final Long shareTransactionId, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || shareTransactionId == null) {
return null;
}
try {
final List<String> nativeTxnIds = jdbcTemplate.queryForList(
"SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", String.class,
shareTransactionId);
if (nativeTxnIds.isEmpty()) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT je.type_enum, je.amount, ga.id AS gl_id, ga.name AS gl_name, ga.gl_code
FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id
WHERE je.transaction_id = ? AND je.reversed = false
ORDER BY je.id DESC
LIMIT 2
""", nativeTxnIds.get(0));
if (rows.isEmpty()) {
return null;
}
// Restore Dr-then-Cr reading order after the DESC/LIMIT above.
java.util.Collections.reverse(rows);
final List<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Long glId = ((Number) r.get("gl_id")).longValue();
final BigDecimal amount = (BigDecimal) r.get("amount");
final boolean isDebit = ((Number) r.get("type_enum")).intValue() == DEBIT;
lines.add(isDebit ? VoucherLine.debitGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount)
: VoucherLine.creditGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount));
}
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a Sundry-only voucher for share withdrawal request {}", requestId, e);
return null;
}
}
/**
* Credits the redemption value to the share account's linked savings account, if one exists.
@@ -827,14 +1122,19 @@ public class ClerkShareWithdrawalRequestApiResource {
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
* (Dr <i>whichever GL the settlement payment type's Fund Source maps to</i> / Cr Savings Control) is then
* <b>reversed in full</b> and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
* to be left to the payment type's own mapping in the hope it would land on the same account
* {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
* did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
* met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
* <b>reversed in full</b>, and nothing is posted in its place here: the caller redirects the redemption's own
* credit leg straight onto the Savings Control GL returned below, so the ledger carries one entry — Dr Share
* Capital / Cr Savings Control — instead of a pair routed through Sundry and back out again.
*
* <p>
* The debit side used to be left to the payment type's own Fund Source mapping in the hope it would land on the
* account the redemption leg had credited, netting to zero; when that mapping pointed anywhere else (as it did the
* day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually met, leaving
* Sundry debited for money that was never credited to it. Naming the destination explicitly, and posting only
* once, removes that class of mismatch entirely.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final String resolutionNumber, final Long settlementPaymentTypeId) {
final String resolutionNumber) {
if (shareTransactionId == null) {
return null;
}
@@ -853,52 +1153,102 @@ public class ClerkShareWithdrawalRequestApiResource {
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);
final Long savingsTransactionId = result.getResourceId();
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
// Office comes from the share account the redemption was posted against, not from a join
// through the savings account's client -- a savings account can be held by a group, where
// that join yields nothing and the insert below would fail on a null office.
final Long officeId = toLong(loadShareAccountGlContext(shareAccountId).get("office_id"));
if (officeId == null) {
throw apiError(409, "The share account has no office on it, so the settlement transaction cannot be booked.");
}
final Long savingsTransactionId = directCreditSavingsAccount(savingsAccountId, officeId, settlementAmount, requestedDateStr,
resolutionNumber);
return new Settlement(settlementAmount, savingsTransactionId, glSavingsControl);
}
/**
* Credits the member's savings account as a book transfer: the transaction row and the balance,
* written directly, with no journal entry of its own.
*
* <p>
* The obvious way to do this is Fineract's own deposit command, and that is what this used to
* call. But that command posts its own Dr Cash at Vault / Cr Savings Control pair on the
* assumption that notes came over the counter, which they did not — the money is moving out of
* share capital inside one approval. Reversing that pair afterwards worked, yet left two dead
* rows in the ledger for every redemption and a Cash at Vault leg that had to be explained to
* anyone reading the table. Writing the transaction directly means those rows are never created
* at all, and the redemption's own entry — Dr Share Capital / Cr Savings Control, posted by the
* caller — is the only accounting the event produces.
*
* <p>
* This is the same technique the disbursement register already uses for its own book transfers
* ({@code ClerkDisbursementRequestApiResource.directCreditSavingsAccount()}), for the same
* reason, and carries the same trade-off: the native command's interest recalculation, lien and
* overdraft checks and business events are all skipped. That is acceptable here because a
* redemption settlement is a plain credit of a known amount into an active account on the
* approval date, which is exactly the case those extras do not apply to.
*
* <p>
* The passbook and statement are unaffected: both read {@code m_savings_account_transaction},
* which is what this writes.
*/
private Long directCreditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
final String requestedDateStr, final String resolutionNumber) {
final Map<String, Object> acct = jdbcTemplate
.queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
.findFirst().orElse(null);
if (acct == null) {
throw apiError(409, "The linked savings account " + savingsAccountId + " no longer exists.");
}
final int statusEnum = ((Number) acct.get("status_enum")).intValue();
if (statusEnum != SAVINGS_STATUS_ACTIVE) {
throw apiError(409, "The linked savings account is not active, so the redemption cannot be settled into it.");
}
final BigDecimal currentBalance = acct.get("account_balance_derived") != null
? (BigDecimal) acct.get("account_balance_derived")
: BigDecimal.ZERO;
final java.sql.Date sqlDate = java.sql.Date.valueOf(requestedDateStr.substring(0, 10));
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final long userId = context.authenticatedUser().getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHRDMSTL-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no");
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settled to savings | " + accountNo);
insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settlement credited to savings | " + accountNo);
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
// transaction_type_enum 1 = deposit.
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ "running_balance_derived, is_lien_transaction, "
+ "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
ps.setLong(1, savingsAccountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo);
ps.setBigDecimal(7, currentBalance.add(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
final long savingsTransactionId = Objects.requireNonNull(kh.getKey()).longValue();
return new Settlement(settlementAmount, savingsTransactionId);
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
+ "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
LOG.info("Share redemption settled into savings account {} as transaction {} (Resolution No. {})", savingsAccountId,
savingsTransactionId, resolutionNumber);
return savingsTransactionId;
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@@ -969,58 +1319,65 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
// ── Sundry pass-through helpers ──────────────────────────────────────────
// ── The redemption's own accounting ──────────────────────────────────────
/**
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
* Posts the redemption as one balanced entry against the account the money actually reached.
*
* <p>
* Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
* Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
* member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
* posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
* for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
* GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
* left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
* the same way every other share-capital movement in this codebase does.
* Share capital is debited and the destination credited, and that is the whole entry: Dr Share
* Equity / Cr the linked savings account's control GL, or the GL the clerk picked, or Share Sundry
* where the clerk chose to settle nowhere further. No "Shares Reference" account appears anywhere
* in it. Fineract's own bridge would have credited that account instead — a placeholder for a
* counterparty it cannot know — so the command that creates the share transaction runs inside
* {@link ShareAccountingSuppression#withOwnShareEntries} and posts nothing of its own.
*
* <p>
* A redemption charge, where the product has one, is income and is credited separately: share
* capital carries the full amount plus the charge, the member receives the amount, and the
* difference lands in Income from Fees. With no charge configured the third line is simply absent.
*
* <p>
* The transaction_id is the one the native command would have used ({@code "SH" + id}), so the
* entry groups and reconciles exactly as every other share posting in the platform does, and
* anything reading a share transaction's entries by that id keeps working unchanged.
*/
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
final long userId) {
private void postRedemptionEntry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
final long userId, final Long destinationGlId, final String narration) {
final Map<String, Object> txn = jdbcTemplate
.queryForList("SELECT amount, COALESCE(charge_amount, 0) AS charge_amount FROM m_share_account_transactions WHERE id = ?",
shareTransactionId)
.stream().findFirst().orElse(null);
if (txn == null) {
return;
}
final BigDecimal amount = (BigDecimal) txn.get("amount");
final BigDecimal charge = (BigDecimal) txn.get("charge_amount");
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final String accountNo = (String) acct.get("account_no");
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Map<String, Object> reversedLeg = jdbcTemplate
.queryForList(
"SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
+ " AND type_enum = ? AND reversed = false",
shareTransactionId, glReference, CREDIT)
.stream().findFirst().orElse(null);
if (reversedLeg == null) {
// Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
// unexpected accounting configuration), so there is no leg here to reroute.
return;
}
final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
// Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
// grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
// Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
// instead of the two reading as the one balanced entry they actually are.
final String nativeTxnId = (String) reversedLeg.get("transaction_id");
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
+ " AND type_enum = ? AND reversed = false",
shareTransactionId, glReference, CREDIT);
final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Equity");
final boolean hasCharge = charge != null && charge.compareTo(BigDecimal.ZERO) > 0;
final String nativeTxnId = SHARE_TXN_ID_PREFIX + shareTransactionId;
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption held in Sundry pending settlement | " + accountNo);
insertEntry(officeId, glEquity, DEBIT, hasCharge ? amount.add(charge) : amount, nativeTxnId, entryDate, shareAccountId,
shareTransactionId, userId, now, currencyCode, "Share redemption | " + accountNo);
insertEntry(officeId, destinationGlId, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now,
currencyCode, narration + " | " + accountNo);
if (hasCharge) {
final Long glFees = requireShareGl(productId, FA_INCOME_FROM_FEES, "Income from Fees");
insertEntry(officeId, glFees, CREDIT, charge, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now,
currencyCode, "Share redemption charge | " + accountNo);
}
}
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
@@ -1050,6 +1407,39 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
/** The GL account's own code, for the {@code accountNo} printed on a voucher's GL-side line --
* never the account/party this GL entry happens to relate to, which is what the "name" already
* carries. Null-safe so a caller can pass a possibly-missing gl account id straight through. */
private String glCodeOf(final Long glAccountId) {
if (glAccountId == null) {
return null;
}
try {
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
} catch (final IncorrectResultSizeDataAccessException e) {
return null;
}
}
/** The GL account's own configured name (e.g. "SUNDRY CREDITORS A/C"), for the party label on a
* voucher's GL-side line -- fixed labels like "Share Sundry" or "Cash in Hand" used to stand in
* for whatever the tenant's own chart of accounts actually maps to (shares-suspense, cash at
* vault), so a voucher read as crediting/debiting a party that doesn't exist in the ledger at all
* whenever the real mapped account was named differently. Null-safe: a missing/unmapped account
* falls back to a neutral label rather than one of those specific fixed names, which would be
* actively wrong when reused for a different GL account (as glCodeOf's callers do). */
private String glNameOf(final Long glAccountId) {
if (glAccountId == null) {
return "GL Account";
}
try {
final String name = jdbcTemplate.queryForObject("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
return name != null ? name : "GL Account";
} catch (final IncorrectResultSizeDataAccessException e) {
return "GL Account";
}
}
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
@@ -1059,19 +1449,31 @@ public class ClerkShareWithdrawalRequestApiResource {
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
/**
* {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual
* entry normally would: these rows stand in for the ones the platform's own bridge would have
* posted, so they have to be indistinguishable from them to everything that goes looking.
*
* <p>
* The voucher builders here match on {@code share_transaction_id}; Fineract's own
* {@code revertShareAccountJournalEntries} matches on {@code transaction_id} <i>and</i>
* {@code entity_type_enum = SHARES}, and would silently skip a row missing that column, leaving
* the accounting standing after the transaction behind it had been reverted.
*/
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId,
final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
@@ -484,7 +484,7 @@ public class ClerkWithdrawalRequestApiResource {
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
addWithdrawalVoucherLines(record, savingsAccountId, amount);
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -735,28 +735,61 @@ public class ClerkWithdrawalRequestApiResource {
* Records the two sides of an approved withdrawal on its voucher.
*
* Money leaves the member and is paid out over the counter, so the member's account is debited
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
* and the till is credited. The till side can't be resolved from config the way a Share or
* Closure withdrawal's cash leg can: which GL Fineract's native withdrawal command actually
* credits depends on the request's own payment type (Cash, Cheque, DD, ...) via its Fund Source
* mapping, which this class does not re-derive. Reading back what was actually posted for this
* transaction (see resolveTillGlFromSavingsTxn()) names it accurately for any payment type;
* "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a current
* account withdrawal, or a tenant with accounting off).
*
* A failure here does not fail the approval: the withdrawal is already posted and the member
* already paid. The problem is logged, and the voucher keeps its number without sides.
*/
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount,
final String savingsTxnId) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
return;
}
try {
final Map<String, Object> tillGl = resolveTillGlFromSavingsTxn(savingsTxnId);
voucherService.addLines(voucherNo,
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
(String) record.get("account_no"), amount),
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
tillGl == null ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
: VoucherLine.creditGl(((Number) tillGl.get("id")).longValue(), (String) tillGl.get("name"),
(String) tillGl.get("code"), amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
}
}
/** The GL account Fineract's native withdrawal command actually credited for this savings
* transaction — looked up by the {@code savings_transaction_id} FK on {@code
* acc_gl_journal_entry} rather than assumed, since the real posting depends on the withdrawal's
* own payment type. Returns null (falls back to a generic label) for a current-account
* withdrawal, which has no savings transaction to match, or a tenant with accounting off. */
private Map<String, Object> resolveTillGlFromSavingsTxn(final String savingsTxnId) {
if (savingsTxnId == null) {
return null;
}
final Long id;
try {
id = Long.valueOf(savingsTxnId);
} catch (final NumberFormatException e) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT g.id, g.name, g.gl_code AS code FROM acc_gl_journal_entry je
JOIN acc_gl_account g ON g.id = je.account_id
WHERE je.savings_transaction_id = ? AND je.type_enum = 1 AND je.reversed = false
LIMIT 1
""", id);
return rows.isEmpty() ? null : rows.get(0);
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -0,0 +1,56 @@
/**
* 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.ToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData;
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/risk-review/behaviour-windows")
@Component
@Tag(name = "Client Behaviour Windows", description = "What each customer's money did over the review period, and the rating that suggests.")
@RequiredArgsConstructor
public class ClientBehaviourWindowApiResource {
private final PlatformSecurityContext context;
private final ClientBehaviourWindowReadPlatformService readPlatformService;
private final ToApiJsonSerializer<ClientBehaviourWindowData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "The latest behaviour window for each customer",
description = "One row per customer, carrying the period's figures and the rating the policy suggests from them. Computed nightly; the screen shows what the suggestion was drawn from rather than recomputing it.")
public String retrieveAll() {
this.context.authenticatedUser();
final List<ClientBehaviourWindowData> windows = this.readPlatformService.retrieveLatestPerClient();
return this.toApiJsonSerializer.serialize(windows);
}
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* One customer's review period as the Risk Review screen shows it.
*
* <p>
* The figures travel with the suggestion rather than being recomputed by the screen. A recommendation an officer is
* asked to approve has to show what it was drawn from - months later, the same row must still explain itself - and a
* second calculation in the browser could disagree with the one the policy actually used.
*/
public record ClientBehaviourWindowData(Long clientId, String clientName, String accountNo, String officeName,
LocalDate windowFrom, LocalDate windowTo, Integer txnCount, Integer distinctMonths, BigDecimal creditTotal,
BigDecimal debitTotal, BigDecimal largestTxn, Integer cashTxnCount, BigDecimal cashTotal, Integer largeTxnCount,
BigDecimal declaredAnnualIncome, BigDecimal incomeRatio, Integer alertCount, Integer strCaseCount,
Integer riskCategoryAtReview, String suggestedDirection, Integer suggestedCategory, String suggestionReason)
implements Serializable {
}
@@ -97,6 +97,20 @@ public final class ClientKycApiConstants {
public static final String form15ApplicableParam = "form15Applicable";
public static final String form15TypeParam = "form15Type";
public static final String form15FYParam = "form15FY";
/**
* Customer facts the risk re-rating policy tests against.
*
* <p>
* A politically exposed person is not downgraded because their activity looks ordinary - that is precisely the
* risk - and the same holds for a sanctions or adverse-media match. The declared figures are what "turnover out of
* keeping with the customer's stated income" is measured against.
*/
public static final String isPepParam = "isPep";
public static final String sanctionsMatchParam = "sanctionsMatch";
public static final String adverseMediaMatchParam = "adverseMediaMatch";
public static final String declaredAnnualIncomeParam = "declaredAnnualIncome";
public static final String declaredAnnualTurnoverParam = "declaredAnnualTurnover";
public static final String isExceptionParam = "isException";
public static final String exceptionReasonParam = "exceptionReason";
public static final String exceptionResolvedParam = "exceptionResolved";
@@ -117,7 +131,9 @@ public final class ClientKycApiConstants {
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam,
isPepParam, sanctionsMatchParam, adverseMediaMatchParam,
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
@@ -133,7 +149,9 @@ public final class ClientKycApiConstants {
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam,
isPepParam, sanctionsMatchParam, adverseMediaMatchParam,
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -122,6 +122,13 @@ public final class ClientKycData {
private final String form15Type;
private final String form15Fy;
// ── Risk profile ─────────────────────────────────────────────────────────
private final Boolean isPep;
private final Boolean sanctionsMatch;
private final Boolean adverseMediaMatch;
private final java.math.BigDecimal declaredAnnualIncome;
private final java.math.BigDecimal declaredAnnualTurnover;
// ── CKYCRR ────────────────────────────────────────────────────────────────
private final String ckycNumber;
private final LocalDate ckycUploadedOn;
@@ -0,0 +1,60 @@
/**
* 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.jobs.clientbehaviourwindow;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService;
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.transaction.PlatformTransactionManager;
@Configuration
public class ClientBehaviourWindowJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private ClientBehaviourWindowService clientBehaviourWindowService;
@Bean
public ClientBehaviourWindowTasklet clientBehaviourWindowTasklet() {
return new ClientBehaviourWindowTasklet(clientBehaviourWindowService);
}
@Bean
protected Step clientBehaviourWindowStep() {
return new StepBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository)
.tasklet(clientBehaviourWindowTasklet(), transactionManager).build();
}
@Bean
public Job clientBehaviourWindowJob() {
return new JobBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository).start(clientBehaviourWindowStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,45 @@
/**
* 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.jobs.clientbehaviourwindow;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService;
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;
/**
* Recomputes every customer's behaviour window and the rating each one suggests.
*
* <p>
* The job only ever writes a suggestion. Moving a rating remains a decision an officer makes and a checker approves,
* which is what a re-rating is: a judgement about a customer, recorded by someone who is accountable for it.
*/
@RequiredArgsConstructor
public class ClientBehaviourWindowTasklet implements Tasklet {
private final ClientBehaviourWindowService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
service.computeForAllClients();
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,80 @@
/**
* 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 lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Serves the most recent behaviour window for each customer, with the customer resolved.
*
* <p>
* Only the latest window per customer: the table keeps each night's computation, and a review screen showing a
* customer once per night would be unreadable within a week.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClientBehaviourWindowReadPlatformService {
private static final String SQL = """
SELECT w.client_id, c.display_name AS client_name, c.account_no, o.name AS office_name,
w.window_from, w.window_to, w.txn_count, w.distinct_months, w.credit_total, w.debit_total, w.largest_txn,
w.cash_txn_count, w.cash_total, w.large_txn_count, w.declared_annual_income, w.income_ratio,
w.alert_count, w.str_case_count, w.risk_category_at_review,
w.suggested_direction, w.suggested_category, w.suggestion_reason
FROM m_client_behaviour_window w
JOIN m_client c ON c.id = w.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
-- The latest window per customer. DISTINCT ON keeps the first row of each client group, and the ORDER BY
-- is what makes that the newest one.
WHERE w.id IN (SELECT DISTINCT ON (client_id) id FROM m_client_behaviour_window
ORDER BY client_id, window_to DESC, computed_on DESC, id DESC)
-- A window describes the rating the customer held when the job ran. Once an officer moves that rating
-- the window is describing a customer who no longer exists, and serving it would have the screen go on
-- asking for a change that has already been applied and approved. Suppressed until tonight's run
-- recomputes it against the rating now on file.
AND (w.risk_category_at_review IS NULL OR k.risk_category IS NULL
OR w.risk_category_at_review = k.risk_category)
ORDER BY CASE w.suggested_direction WHEN 'UP' THEN 0 WHEN 'DOWN' THEN 1 ELSE 2 END,
c.display_name
""";
private final JdbcTemplate jdbcTemplate;
public List<ClientBehaviourWindowData> retrieveLatestPerClient() {
return jdbcTemplate.query(SQL, (rs, rowNum) -> new ClientBehaviourWindowData(rs.getLong("client_id"),
rs.getString("client_name"), rs.getString("account_no"), rs.getString("office_name"),
rs.getDate("window_from") == null ? null : rs.getDate("window_from").toLocalDate(),
rs.getDate("window_to") == null ? null : rs.getDate("window_to").toLocalDate(),
(Integer) rs.getObject("txn_count"), (Integer) rs.getObject("distinct_months"), rs.getBigDecimal("credit_total"),
rs.getBigDecimal("debit_total"),
rs.getBigDecimal("largest_txn"), (Integer) rs.getObject("cash_txn_count"), rs.getBigDecimal("cash_total"),
(Integer) rs.getObject("large_txn_count"), rs.getBigDecimal("declared_annual_income"),
rs.getBigDecimal("income_ratio"), (Integer) rs.getObject("alert_count"),
(Integer) rs.getObject("str_case_count"), (Integer) rs.getObject("risk_category_at_review"),
rs.getString("suggested_direction"), (Integer) rs.getObject("suggested_category"),
rs.getString("suggestion_reason")));
}
}
@@ -0,0 +1,247 @@
/**
* 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.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindow;
import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindowRepository;
import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.BehaviourFacts;
import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.Recommendation;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Computes what each customer's money did over a period, and asks the policy what that means for their rating.
*
* <p>
* Run nightly. The figures are aggregated in the database rather than by loading each customer's transactions into
* memory: a book of any size would otherwise read every transaction row it has ever written, once per customer, every
* night.
*
* <p>
* Two windows are computed per customer, not one. An upgrade asks what the customer is doing now and reads a short
* window; a downgrade asks whether a year of conduct supports removing a rating. Computing only the longer window
* would delay every upgrade by months, and only the shorter would lower ratings on a quiet fortnight.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ClientBehaviourWindowService {
/**
* One customer's aggregated activity for one window.
*
* <p>
* Every count and total is computed in SQL; nothing here re-reads transactions.
*/
private record Aggregate(Long clientId, Integer currentCategory, int txnCount, int distinctMonths, BigDecimal creditTotal,
BigDecimal debitTotal, BigDecimal largestTxn, int cashTxnCount, BigDecimal cashTotal, int largeTxnCount,
int ctrTxnCount, int mediumAlertCount, int highAlertCount, int openStrCount, int strInBlockWindow, boolean pep,
boolean sanctionsMatch, boolean adverseMedia, boolean graveRuleFired, boolean kycOverdue,
Integer daysInCurrentCategory, BigDecimal declaredIncome, BigDecimal declaredTurnover) {}
/**
* Everything the policy tests, for every active customer, in one pass.
*
* <p>
* Each subquery is scoped to the same window so the figures describe one period. Alerts are counted by their worst
* matched severity rather than by the rule the alert was raised under, because a statutory CTR outranks a
* behavioural rule when the governing rule is picked - counting the governing rule would lose the high-severity
* finding underneath it.
*/
private static final String AGGREGATE_SQL = """
SELECT c.id AS client_id,
k.risk_category AS current_category,
COALESCE(t.txn_count, 0) AS txn_count,
COALESCE(t.distinct_months, 0) AS distinct_months,
COALESCE(t.credit_total, 0) AS credit_total,
COALESCE(t.debit_total, 0) AS debit_total,
COALESCE(t.largest_txn, 0) AS largest_txn,
COALESCE(t.cash_txn_count, 0) AS cash_txn_count,
COALESCE(t.cash_total, 0) AS cash_total,
COALESCE(t.large_txn_count, 0) AS large_txn_count,
COALESCE(t.ctr_txn_count, 0) AS ctr_txn_count,
COALESCE(a.medium_alerts, 0) AS medium_alerts,
COALESCE(a.high_alerts, 0) AS high_alerts,
COALESCE(a.grave_rule, false) AS grave_rule,
COALESCE(s.open_str, 0) AS open_str,
COALESCE(s.str_in_block_window, 0) AS str_in_block_window,
COALESCE(k.is_pep, false) AS pep,
COALESCE(k.sanctions_match, false) AS sanctions_match,
COALESCE(k.adverse_media_match, false) AS adverse_media,
CASE WHEN k.expiry_date IS NOT NULL AND k.expiry_date < ? THEN true ELSE false END AS kyc_overdue,
h.days_in_category,
k.declared_annual_income,
k.declared_annual_turnover
FROM m_client c
LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
-- Savings activity in the window, by client.
LEFT JOIN (
SELECT sa.client_id,
count(*) AS txn_count,
count(DISTINCT to_char(tx.transaction_date, 'YYYY-MM')) AS distinct_months,
sum(CASE WHEN tx.transaction_type_enum = 1 THEN tx.amount ELSE 0 END) AS credit_total,
sum(CASE WHEN tx.transaction_type_enum = 2 THEN tx.amount ELSE 0 END) AS debit_total,
max(tx.amount) AS largest_txn,
count(*) FILTER (WHERE pt.is_cash_payment) AS cash_txn_count,
COALESCE(sum(tx.amount) FILTER (WHERE pt.is_cash_payment), 0) AS cash_total,
count(*) FILTER (WHERE tx.amount >= ?) AS large_txn_count,
count(*) FILTER (WHERE pt.is_cash_payment AND tx.amount >= ?) AS ctr_txn_count
FROM m_savings_account_transaction tx
JOIN m_savings_account sa ON sa.id = tx.savings_account_id
LEFT JOIN m_payment_detail pd ON pd.id = tx.payment_detail_id
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE tx.is_reversed = false
AND tx.transaction_type_enum IN (1, 2)
AND tx.transaction_date BETWEEN ? AND ?
GROUP BY sa.client_id
) t ON t.client_id = c.id
-- Alerts in the window, graded by the worst rule each matched.
LEFT JOIN (
SELECT al.client_id,
count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 2) AS medium_alerts,
count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 3) AS high_alerts,
bool_or(ar.rule_code IS NOT NULL) AS grave_rule
FROM m_aml_alert al
LEFT JOIN m_aml_alert_rule ar
ON ar.alert_id = al.id
AND (ar.rule_code LIKE 'STRUCTUR%' OR ar.rule_code LIKE 'PEP%'
OR ar.rule_code LIKE 'SANCTION%' OR ar.rule_code LIKE 'ADVERSE_MEDIA%'
OR ar.rule_code LIKE 'HIGH_RISK_COUNTRY%' OR ar.rule_code LIKE 'WILFUL_DEFAULTER%')
WHERE al.raised_on >= ? AND al.raised_on < ?
GROUP BY al.client_id
) a ON a.client_id = c.id
-- STR cases: open ones hold a rating at High; recent concluded ones block a downgrade.
LEFT JOIN (
SELECT sc.client_id,
count(*) FILTER (WHERE sc.status = 'OPEN') AS open_str,
count(*) FILTER (WHERE sc.created_on_utc >= ?) AS str_in_block_window
FROM m_aml_str_case sc
GROUP BY sc.client_id
) s ON s.client_id = c.id
-- How long the customer has held the rating they are on, from the last recorded change.
LEFT JOIN (
SELECT client_id, (? - max(changed_on)::date) AS days_in_category
FROM m_client_risk_category_history
GROUP BY client_id
) h ON h.client_id = c.id
WHERE c.status_enum = 300
""";
private final JdbcTemplate jdbcTemplate;
private final ClientBehaviourWindowRepository windowRepository;
private final RiskMovementPolicyService policyService;
/** ₹10,00,000 - the CTR line, and the figure "large transaction" means here. */
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
/**
* Computes tonight's windows for every active customer.
*
* <p>
* The upgrade window is written for every customer; the downgrade window only for those rated Medium or High,
* since there is nothing below Low to move to. The suggestion is taken from whichever window the policy acted on,
* so the figures shown beside a recommendation are the ones it was drawn from.
*/
@Transactional
public int computeForAllClients() {
final LocalDate today = DateUtils.getBusinessLocalDate();
windowRepository.deleteByWindowTo(today);
final int upgradeDays = policyService.windowDaysFor("UPGRADE_WINDOW_DAYS", 90);
final int highToMediumDays = policyService.windowDaysFor("HIGH_TO_MEDIUM_WINDOW_DAYS", 365);
final int mediumToLowDays = policyService.windowDaysFor("MEDIUM_TO_LOW_WINDOW_DAYS", 270);
final int strBlockMonths = policyService.windowDaysFor("STR_BLOCK_MONTHS", 24);
final List<Aggregate> shortWindow = aggregate(today.minusDays(upgradeDays), today, strBlockMonths);
int written = 0;
for (final Aggregate shortAgg : shortWindow) {
final Integer category = shortAgg.currentCategory();
// A downgrade is judged over the window that band requires; Low has nothing below it.
final int longDays = category != null && category == RiskMovementPolicyService.HIGH ? highToMediumDays : mediumToLowDays;
final Aggregate longAgg = category != null && category > RiskMovementPolicyService.LOW
? one(today.minusDays(longDays), today, strBlockMonths, shortAgg.clientId())
: null;
final Recommendation shortResult = policyService.evaluate(factsOf(shortAgg));
// The short window decides upgrades and the standing facts; anything else is a question for the long one.
final boolean actedOnShort = RiskMovementPolicyService.UP.equals(shortResult.direction()) || longAgg == null;
final Aggregate acted = actedOnShort ? shortAgg : longAgg;
final Recommendation result = actedOnShort ? shortResult : policyService.evaluate(factsOf(longAgg));
final LocalDate from = actedOnShort ? today.minusDays(upgradeDays) : today.minusDays(longDays);
final ClientBehaviourWindow window = ClientBehaviourWindow.of(acted.clientId(), from, today);
window.recordActivity(acted.txnCount(), acted.creditTotal(), acted.debitTotal(), acted.largestTxn(),
acted.cashTxnCount(), acted.cashTotal(), acted.largeTxnCount());
window.recordDistinctMonths(acted.distinctMonths());
window.recordMonitoring(acted.mediumAlertCount(), acted.openStrCount(), acted.currentCategory());
window.recordDeclared(acted.declaredIncome(), acted.declaredTurnover(),
acted.creditTotal() == null ? null : acted.creditTotal().add(acted.debitTotal()));
window.recordSuggestion(result.direction(), result.suggestedCategory(), result.reason());
windowRepository.save(window);
written++;
}
log.info("Client behaviour windows computed for {} clients as at {}", written, today);
return written;
}
private BehaviourFacts factsOf(final Aggregate a) {
final BigDecimal periodTotal = a.creditTotal().add(a.debitTotal());
return new BehaviourFacts(a.currentCategory(), a.txnCount(), a.distinctMonths(), a.cashTotal(), periodTotal,
a.largestTxn(), a.ctrTxnCount(), a.mediumAlertCount(), a.highAlertCount(), a.openStrCount(),
a.strInBlockWindow(), a.pep(), a.sanctionsMatch(), a.adverseMedia(), a.graveRuleFired(), a.kycOverdue(),
a.daysInCurrentCategory());
}
private List<Aggregate> aggregate(final LocalDate from, final LocalDate to, final int strBlockMonths) {
return query(AGGREGATE_SQL, from, to, strBlockMonths, null);
}
private Aggregate one(final LocalDate from, final LocalDate to, final int strBlockMonths, final Long clientId) {
final List<Aggregate> rows = query(AGGREGATE_SQL + " AND c.id = ? ", from, to, strBlockMonths, clientId);
return rows.isEmpty() ? null : rows.get(0);
}
private List<Aggregate> query(final String sql, final LocalDate from, final LocalDate to, final int strBlockMonths,
final Long clientId) {
final List<Object> args = new ArrayList<>(List.of(to, CTR_THRESHOLD, CTR_THRESHOLD, from, to, from.atStartOfDay(),
to.plusDays(1).atStartOfDay(), to.minusMonths(strBlockMonths).atStartOfDay(), to));
if (clientId != null) {
args.add(clientId);
}
return jdbcTemplate.query(sql, (rs, rowNum) -> new Aggregate(rs.getLong("client_id"),
rs.getObject("current_category") == null ? null : rs.getInt("current_category"), rs.getInt("txn_count"),
rs.getInt("distinct_months"), rs.getBigDecimal("credit_total"), rs.getBigDecimal("debit_total"),
rs.getBigDecimal("largest_txn"), rs.getInt("cash_txn_count"), rs.getBigDecimal("cash_total"),
rs.getInt("large_txn_count"), rs.getInt("ctr_txn_count"), rs.getInt("medium_alerts"), rs.getInt("high_alerts"),
rs.getInt("open_str"), rs.getInt("str_in_block_window"), rs.getBoolean("pep"), rs.getBoolean("sanctions_match"),
rs.getBoolean("adverse_media"), rs.getBoolean("grave_rule"), rs.getBoolean("kyc_overdue"),
rs.getObject("days_in_category") == null ? null : rs.getInt("days_in_category"),
rs.getBigDecimal("declared_annual_income"), rs.getBigDecimal("declared_annual_turnover")),
args.toArray());
}
}
@@ -132,6 +132,11 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.form15Applicable(kyc.getForm15Applicable())
.form15Type(kyc.getForm15Type())
.form15Fy(kyc.getForm15Fy())
.isPep(kyc.isPep())
.sanctionsMatch(kyc.isSanctionsMatch())
.adverseMediaMatch(kyc.isAdverseMediaMatch())
.declaredAnnualIncome(kyc.getDeclaredAnnualIncome())
.declaredAnnualTurnover(kyc.getDeclaredAnnualTurnover())
.ckycNumber(kyc.getCkycNumber())
.ckycUploadedOn(kyc.getCkycUploadedOn())
.ckycBatchId(kyc.getCkycBatchId())
@@ -0,0 +1,276 @@
/**
* 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.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.domain.RiskPolicyParameter;
import org.apache.fineract.portfolio.client.domain.RiskPolicyParameterRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Decides where a customer's rating should sit, given a period of their behaviour.
*
* <p>
* Two principles shape every rule here, and they are not symmetric.
*
* <p>
* <b>A rating never moves on one transaction.</b> Not up and not down. A single shop purchase does not argue for
* lowering a High rating, and a single large deposit does not make an ordinary customer suspicious - it may be a
* property sale. Both are alerts worth an officer's attention, and neither is a pattern. The minimum counts below are
* the same test the transaction rules already apply to themselves.
*
* <p>
* <b>Suspicion moves faster than confidence.</b> A concern is about what the customer is doing now, so upgrades read a
* short window. Removing a High rating is a judgement that an earlier concern no longer holds, which needs a year of
* evidence spread across months rather than a quiet fortnight. The one exception is the set of facts that are not
* about behaviour at all - an STR, a sanctions hit, a PEP determination - which move a rating immediately because no
* amount of ordinary transacting answers them.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RiskMovementPolicyService {
public static final int LOW = 1;
public static final int MEDIUM = 2;
public static final int HIGH = 3;
public static final String UP = "UP";
public static final String DOWN = "DOWN";
public static final String HOLD = "HOLD";
private final RiskPolicyParameterRepository parameterRepository;
/** One customer's period, as the policy needs to see it. */
public record BehaviourFacts(Integer currentCategory, int txnCount, int distinctMonths, BigDecimal cashTotal,
BigDecimal periodTotal, BigDecimal largestTxn, int ctrTxnCount, int mediumAlertCount, int highAlertCount,
int openStrCount, int strCountInBlockWindow, boolean pep, boolean sanctionsMatch, boolean adverseMedia,
boolean graveRuleFired, boolean kycOverdue, Integer daysInCurrentCategory) {}
/** What the policy concluded, and the sentence an officer reads. */
public record Recommendation(String direction, Integer suggestedCategory, String reason) {}
public Recommendation evaluate(final BehaviourFacts f) {
final Map<String, BigDecimal> p = parameters();
// ── Facts that are not about behaviour ──────────────────────────────────────────────────
// These move a rating on their own. No quantity of ordinary transacting answers a sanctions match, and an STR
// is the bank's own statement that it found the customer's activity suspicious.
if (f.sanctionsMatch()) {
return high(f, "Sanctions or watch-list match — rating held at High irrespective of activity.");
}
if (f.pep()) {
return high(f, "Politically exposed person — High is the standing rating, not a conclusion about this period.");
}
if (f.adverseMedia()) {
return high(f, "Adverse media or regulatory action on record — rating held at High.");
}
if (f.openStrCount() > 0) {
return high(f, "An STR case is open — rating held at High until the Principal Officer concludes it.");
}
// ── Upgrades ────────────────────────────────────────────────────────────────────────────
// Read before the downgrade tests: a customer whose activity has grown is not a candidate for lowering, and
// saying so is more useful than "insufficient activity to lower".
final Recommendation up = considerUpgrade(f, p);
if (up != null) {
return up;
}
// ── Downgrades ──────────────────────────────────────────────────────────────────────────
return considerDowngrade(f, p);
}
private Recommendation considerUpgrade(final BehaviourFacts f, final Map<String, BigDecimal> p) {
final int current = f.currentCategory() == null ? LOW : f.currentCategory();
if (current >= HIGH) {
return null;
}
// A pattern, even to raise a rating. One large transaction is an alert for an officer to look at, not grounds
// for re-rating the customer who made it.
if (f.txnCount() < intOf(p, "MIN_TXN_FOR_UPGRADE", 5)) {
return null;
}
if (current == MEDIUM) {
if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) {
return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount()));
}
if (f.graveRuleFired()) {
return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount.");
}
if (atLeast(f.cashTotal(), p.get("MED_TO_HIGH_CASH_TOTAL"))) {
return raise(HIGH, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal())));
}
return null;
}
// Low, or unrated and treated as Low.
if (f.graveRuleFired()) {
return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount.");
}
if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) {
return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount()));
}
if (atLeast(f.cashTotal(), p.get("LOW_TO_MED_CASH_TOTAL"))) {
return raise(MEDIUM, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal())));
}
if (f.ctrTxnCount() >= intOf(p, "LOW_TO_MED_CTR_COUNT", 3)) {
return raise(MEDIUM, "%d CTR-reportable transactions in the review window.".formatted(f.ctrTxnCount()));
}
if (f.mediumAlertCount() >= intOf(p, "LOW_TO_MED_ALERT_COUNT", 3)) {
return raise(MEDIUM, "%d alerts of medium severity or worse in the review window.".formatted(f.mediumAlertCount()));
}
return null;
}
private Recommendation considerDowngrade(final BehaviourFacts f, final Map<String, BigDecimal> p) {
if (f.currentCategory() == null) {
// An unrated customer who is transacting is a gap in the book, not a quiet account. Saying only "no rating
// on record" leaves an officer nothing to act on, so the size of what went through is named: an unrated
// customer who has moved reportable sums needs rating now, not at the next periodic review.
final boolean reportable = f.ctrTxnCount() > 0 || atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"));
return new Recommendation(HOLD, null, reportable
? "No rating on record, and %s passed through the account in the window — the customer must be rated before this activity can be judged."
.formatted(inr(f.periodTotal()))
: "No rating on record — the customer must be rated before behaviour can be compared against it.");
}
final int current = f.currentCategory();
if (current <= LOW) {
return hold(f, "Already at the lowest rating.");
}
// Blocks. Each is a reason the period's figures cannot speak for the customer, so they are tested before the
// figures are.
if (f.strCountInBlockWindow() > 0) {
return hold(f, "An STR was filed within the last %d months — rating held.".formatted(intOf(p, "STR_BLOCK_MONTHS", 24)));
}
if (f.graveRuleFired()) {
return hold(f, "Structuring or an equivalent pattern on record — small amounts are not a reason to lower this.");
}
if (f.kycOverdue()) {
return hold(f, "KYC is overdue or documents have expired — the rating cannot be lowered on an unverified record.");
}
final boolean toMedium = current == HIGH;
final int minTxn = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_TXN" : "MEDIUM_TO_LOW_MIN_TXN", toMedium ? 20 : 12);
final int minMonths = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_MONTHS" : "MEDIUM_TO_LOW_MIN_MONTHS", toMedium ? 6 : 5);
final int windowDays = intOf(p, toMedium ? "HIGH_TO_MEDIUM_WINDOW_DAYS" : "MEDIUM_TO_LOW_WINDOW_DAYS", toMedium ? 365 : 270);
if (f.txnCount() < minTxn) {
return hold(f, "Only %d transactions in the window; %d are needed to lower a %s rating.".formatted(f.txnCount(), minTxn,
label(current)));
}
// Spread as well as count: twenty transactions in one week is not a year of steady conduct.
if (f.distinctMonths() < minMonths) {
return hold(f, "Activity falls in %d months; %d separate months are needed.".formatted(f.distinctMonths(), minMonths));
}
if (f.daysInCurrentCategory() != null && f.daysInCurrentCategory() < windowDays) {
return hold(f, "Only %d days at %s; %d are needed before the rating is reconsidered."
.formatted(f.daysInCurrentCategory(), label(current), windowDays));
}
if (f.highAlertCount() > 0) {
return hold(f, "%d high-severity alert(s) in the window — the period is not clean.".formatted(f.highAlertCount()));
}
// Size. Volume alone holds a rating up: a customer moving large sums is not low-risk merely for doing it
// without tripping a rule.
if (atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"))) {
return hold(f, "Turnover of %s in the window holds the rating where it is.".formatted(inr(f.periodTotal())));
}
if (!toMedium) {
// Medium to Low is the stricter of the two: Low is the least-monitored band.
if (!below(f.largestTxn(), p.get("SMALL_TXN_MAX")) || !below(f.periodTotal(), p.get("SMALL_TOTAL_MAX"))) {
return hold(f, "Activity is larger than Low-rated behaviour — largest %s, %s in total."
.formatted(inr(f.largestTxn()), inr(f.periodTotal())));
}
}
// One band at a time. High to Low in a single step would skip the year at Medium that is the evidence for it.
final int suggested = current - 1;
return new Recommendation(DOWN, suggested,
"%d transactions across %d months, %s in total, no high-severity alerts — %s is proportionate."
.formatted(f.txnCount(), f.distinctMonths(), inr(f.periodTotal()), label(suggested)));
}
private Recommendation high(final BehaviourFacts f, final String reason) {
final boolean alreadyHigh = f.currentCategory() != null && f.currentCategory() == HIGH;
return new Recommendation(alreadyHigh ? HOLD : UP, HIGH, reason);
}
private Recommendation raise(final int category, final String reason) {
return new Recommendation(UP, category, reason);
}
private Recommendation hold(final BehaviourFacts f, final String reason) {
return new Recommendation(HOLD, f.currentCategory(), reason);
}
private Map<String, BigDecimal> parameters() {
return parameterRepository.findAll().stream()
.collect(Collectors.toMap(RiskPolicyParameter::getParameterKey, RiskPolicyParameter::getParameterValue, (a, b) -> a));
}
/**
* A parameter's value, or the seeded default.
*
* <p>
* The fallback is not decoration: a tenant whose parameter table has not been seeded must still apply the policy,
* rather than reading a missing row as zero and lowering every rating that asked for "at least 0 transactions".
*/
private int intOf(final Map<String, BigDecimal> p, final String key, final int fallback) {
final BigDecimal value = p.get(key);
return value == null ? fallback : value.intValue();
}
private boolean atLeast(final BigDecimal value, final BigDecimal threshold) {
return value != null && threshold != null && value.compareTo(threshold) >= 0;
}
private boolean below(final BigDecimal value, final BigDecimal threshold) {
return value != null && threshold != null && value.compareTo(threshold) < 0;
}
private String label(final int category) {
return switch (category) {
case LOW -> "Low";
case MEDIUM -> "Medium";
case HIGH -> "High";
default -> "Unrated";
};
}
private String inr(final BigDecimal amount) {
return amount == null ? "nil" : "INR " + amount.setScale(0, java.math.RoundingMode.HALF_UP).toPlainString();
}
/** Exposed so the job can name the windows it must compute without repeating the keys. */
public int windowDaysFor(final String key, final int fallback) {
return intOf(parameters(), key, fallback);
}
public List<String> downgradeWindowKeys() {
return List.of("HIGH_TO_MEDIUM_WINDOW_DAYS", "MEDIUM_TO_LOW_WINDOW_DAYS");
}
}
@@ -47,6 +47,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -520,7 +522,7 @@ public class DividendClaimRequestApiResource {
final Long ddPayableAccountId = ddPayableAccount();
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
final String transactionId = "DIVDD" + claimId;
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLAIM);
// Stamped with the financial year the dividend was DECLARED for, not the year it is being
// claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot
// say how much of each year's declaration has actually been claimed.
@@ -571,12 +573,18 @@ public class DividendClaimRequestApiResource {
final Map<String, Object> debitGl = glAccountForVoucher(debitGlId);
final Map<String, Object> creditGl = glAccountForVoucher(creditGlId);
voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser,
VoucherService.PREFIX_DEBIT,
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId,
currentUser, VoucherService.PREFIX_DEBIT,
List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount)
.withDescription(description),
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
.withDescription(description)));
// Every other maker-checker request that raises a voucher stores its own voucher_no back
// on itself, which is how "My Voucher"/"My Work" finds it -- without this, the voucher
// existed in m_voucher/m_voucher_line but was invisible to the manager who approved it.
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET voucher_no = ? WHERE id = ?", voucherNo, claimId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for dividend claim {}", claimId, e);
}
@@ -1111,19 +1111,34 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
try {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> portfolio = this.voucherService.loanPortfolioAccount(loan.getId());
final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue();
final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null;
final String accountNo = loan.getAccountNumber();
final String partyName = party != null ? party : (String) portfolio.get("name");
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount)
: VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount);
// This is the borrower's own loan account leg, not a GL posting -- debitGl/creditGl(
// portfolioGlId, ...) named the shared Loan Portfolio GL account here, mismatched against
// this borrower's own loan account number on the same line.
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitLoan(loan.getId(), partyName, accountNo, amount)
: VoucherLine.creditLoan(loan.getId(), partyName, accountNo, amount);
// The till. The loan module does not resolve a GL for the cashier's drawer, so the line
// names it without an id rather than guessing at one.
final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
// The till. Which GL the native disbursement/repayment command actually posted here
// depends on the transaction's own payment type (Cash, Cheque, DD, ...) via its Fund
// Source mapping, so it's read back from what was actually posted rather than assumed --
// "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a
// non-cash payment type routed elsewhere, or accounting off for this tenant).
final Map<String, Object> tillGl = this.voucherService.tillGlFromLoanTransaction(transactionId, isDisbursement);
final VoucherLine cashSide;
if (tillGl.isEmpty()) {
cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
} else {
final Long tillGlId = ((Number) tillGl.get("id")).longValue();
final String tillGlName = (String) tillGl.get("name");
final String tillGlCode = (String) tillGl.get("gl_code");
cashSide = isDisbursement ? VoucherLine.creditGl(tillGlId, tillGlName, tillGlCode, amount)
: VoucherLine.debitGl(tillGlId, tillGlName, tillGlCode, amount);
}
final List<VoucherLine> lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
@@ -27,6 +27,8 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -225,7 +227,11 @@ public class SavingsPooledInterestPostingService {
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code");
final String poolReference = "SIP-" + productId + "-" + postingDate;
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
// and the resumed legs have to carry the same reference as the legs already posted, or one
// interest posting ends up split across two transaction ids.
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
productId);
// Stage 1: the debt the accrual has been building moves out of the payable and into the
// account the payout is made from, so what is still merely owed and what is on its way to
@@ -2079,7 +2079,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
final Map<String, Object> expense = this.voucherService.savingsInterestExpenseAccount(account.getId());
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue();
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
@@ -2100,8 +2099,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
.withDescription("Interest posted to " + accountNo),
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
.withDescription("Interest posted to " + accountNo),
// The member's own savings account leg, not a GL posting -- creditGl(
// controlGlId, ...) named the shared Savings Control GL account here,
// mismatched against this account's own number on the same line.
VoucherLine.creditSavings(account.getId(), partyName, accountNo, amount)
.withDescription("Interest credited")));
// Without this the voucher exists but nothing ties it to the transaction it paid, so
// the register cannot show the two together and the transaction shows no voucher_no.
@@ -171,7 +171,7 @@ public class ShareAdditionRequestsApiResource {
final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
return this.jdbcTemplate.queryForList("""
SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate",
collected_by_name AS "collectedByName", status
collected_by_name AS "collectedByName", status, note
FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id
""".formatted(placeholders), ids.toArray());
}
@@ -0,0 +1,147 @@
/**
* 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.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
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.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The Board of Resolution register for share capital: every share account with the resolution that
* authorised it.
*
* <p>
* A co-operative allots share capital only under a board resolution, and the register is what an
* auditor or the Registrar asks for - one line per member showing the resolution number against the
* shares it authorised. Served as a single query rather than assembled on the client, which had to
* fetch each account separately and stopped at two hundred of them.
*
* <p>
* Read-only; nothing here writes.
*/
@Path("/v1/shareaccounts/bor-register")
@Component
@Tag(name = "Share BOR Register",
description = "Share accounts with the board resolution that authorised them, for the BOR register.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareBorRegisterApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* @param status
* m_share_account.status_enum, to show only active or only closed members; omit for
* every account whatever its status, which is what a register is
* @param officeId
* a single branch, for a branch-scoped login
*/
@GET
@Operation(summary = "Share accounts with their board resolution",
description = "One row per share account: member, branch, product, shares held, and the resolution number and date that authorised the allotment.")
public Response retrieveRegister(@QueryParam("status") final Integer status,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser();
// Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so
// queryForList would key the maps "accountno" and "resolutionnumber" - which serialise
// straight into the JSON and leave the client reading accountNo as undefined.
//
// Every date goes through to_char. Gson serialises a java.sql.Date with its default date
// format, which produced "Sept 14, 2026" - a locale-dependent string the client cannot
// parse, sort or compare, and which its ISO reader truncated to "Sept 14, 2". A date column
// is sent as an ISO string so it reads the same wherever it lands.
//
// resolution_date is selected but is null on every row today: the column exists and the
// frontend sends a value, but ShareAccount carries no @Column mapping for it, so the value
// is dropped on save. The meeting date that IS written lives on the purchase transaction,
// so it is read from there and the account's own date is used only as a fallback.
final StringBuilder sql = new StringBuilder("""
SELECT sa.id AS "shareAccountId",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.account_no AS "cifNumber",
c.display_name AS "clientName",
o.id AS "officeId",
o.name AS "officeName",
sp.name AS "productName",
sa.currency_code AS "currencyCode",
sa.status_enum AS "statusEnum",
sa.total_approved_shares AS "approvedShares",
sa.total_pending_shares AS "pendingShares",
sp.unit_price AS "unitPrice",
COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0) AS "shareValue",
sa.resolution_number AS "resolutionNumber",
to_char(COALESCE(sa.resolution_date, txn.first_meeting_date), 'YYYY-MM-DD') AS "resolutionDate",
txn.tranche_count AS "trancheCount",
to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate",
to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate",
to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate",
to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate"
FROM m_share_account sa
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_share_product sp ON sp.id = sa.product_id
LEFT JOIN (
SELECT t.account_id,
MIN(t.meeting_date) AS first_meeting_date,
COUNT(*) AS tranche_count
FROM m_share_account_transactions t
WHERE t.is_active = true AND t.type_enum = 500
GROUP BY t.account_id
) txn ON txn.account_id = sa.id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null) {
sql.append(" AND sa.status_enum = ? ");
args.add(status);
}
if (officeId != null) {
sql.append(" AND o.id = ? ");
args.add(officeId);
}
// Resolution first: the register is read by resolution, and an account with no resolution
// recorded is the thing an auditor is looking for, so those sort to the top rather than
// being buried at the end of the list.
sql.append(" ORDER BY sa.resolution_number NULLS FIRST, sa.account_no ");
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
return Response.ok(gson.toJson(rows)).build();
}
}
@@ -109,6 +109,18 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
@Column(name = "resolution_number", nullable = false)
private String resolutionNumber;
/**
* Date of the board meeting that passed the resolution above.
*
* <p>
* The column has existed since 0470 and the frontend has been sending a value all along, but
* nothing mapped it - so every allotment was saved with the resolution number and without the
* date it was passed on, and the BOR register had nothing to show the Registrar. Nullable
* because the accounts already on file carry no date and cannot be given one retrospectively.
*/
@Column(name = "resolution_date")
private LocalDate resolutionDate;
@Column(name = "account_no", length = 20, unique = true, nullable = false)
private String accountNumber;
@@ -260,6 +272,19 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
return toReturn;
}
public LocalDate getResolutionDate() {
return this.resolutionDate;
}
public boolean setResolutionDate(final LocalDate resolutionDate) {
boolean toReturn = false;
if (this.resolutionDate == null || !this.resolutionDate.equals(resolutionDate)) {
this.resolutionDate = resolutionDate;
toReturn = true;
}
return toReturn;
}
public boolean setSavingsAccount(final SavingsAccount savingsAccount) {
boolean returnValue = false;
if (!this.savingsAccount.getId().equals(savingsAccount.getId())) {
@@ -537,8 +562,31 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
this.totalSharesPending = shares;
}
/**
* Finds this account's own instance of a transaction, after a save has replaced the caller's copy.
*
* <p>
* Matched on identity where there is one. The callers run this right after {@code saveAndFlush},
* by which point the transaction has been assigned its id, and matching on that is exact. The
* field comparison below is the fallback for a transaction that has not been persisted yet.
*
* <p>
* The fallback cannot stand on its own, because the fields it compares do not identify a
* transaction: two redemptions of the same number of shares, at the same price, on the same day
* are identical in every one of them. When that happened the loop returned the <i>earlier</i> of
* the two and the caller went on to treat the new transaction as that one -- posting the second
* redemption's journal entries under the first one's transaction id, so one share transaction
* appeared to carry two redemptions and its voucher showed double the money that actually moved.
*/
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
ShareAccountTransaction returnTrans = null;
if (transaction.getId() != null) {
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
if (transaction.getId().equals(tran.getId())) {
return tran;
}
}
}
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
// A charge/fee transaction carries no share count or unit price at all (both columns are
// null), so this account's own transaction list is not uniformly non-null the way a
@@ -238,6 +238,14 @@ public class ShareAccountDataSerializer {
minimumActivePeriod, minimumActivePeriodEnum, charges, submittedBy, submittedDate, approvedBy, approvedDate, rejectedBy,
rejectedDate, activatedBy, activatedDate, closedBy, closedDate, modifiedBy, modifiedDate);
// Set after construction rather than threaded through the constructor: it already takes
// twenty-eight arguments, and every caller would have to change to carry one optional date.
final LocalDate resolutionDate = this.fromApiJsonHelper
.extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element);
if (resolutionDate != null) {
account.setResolutionDate(resolutionDate);
}
for (ShareAccountTransaction pur : sharesPurchased) {
pur.setShareAccount(account);
}
@@ -326,6 +334,14 @@ public class ShareAccountDataSerializer {
}
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.resolutiondate_paramname, element)) {
final LocalDate resolutionDate = this.fromApiJsonHelper
.extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element);
if (account.setResolutionDate(resolutionDate)) {
actualChanges.put(ShareAccountApiConstants.resolutiondate_paramname, resolutionDate);
}
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) {
final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element);
if (account.setLinkedLoanId(linkedLoanId)) {
@@ -783,6 +799,15 @@ public class ShareAccountDataSerializer {
advanceCollectionIds = extractAdvanceCollectionIds(element);
if (advanceCollectionIds.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull();
} else if (advanceCollectionIds.size() > 1) {
// Additional-share tranches fund from exactly one receipt for now -- combining
// instalments across several is the first-application Apply Collection dialog's own
// behaviour, not this one's, until this screen is asked to support it too.
baseDataValidator.reset().parameter(ShareAccountApiConstants.additionalcollectionids_paramname)
.value(advanceCollectionIds.size())
.failWithCodeAndMessage("share.tranche.single.voucher.only",
"Only one voucher can currently fund an additional-share tranche. " + advanceCollectionIds.size()
+ " were submitted.");
} else {
validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator);
}
@@ -851,7 +876,7 @@ public class ShareAccountDataSerializer {
advanceCollectionId);
if (rows.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found.");
.failWithCodeAndMessage("share.advance.collection.not.found", "A voucher for this payment could not be found.");
continue;
}
final Map<String, Object> collection = rows.get(0);
@@ -862,7 +887,7 @@ public class ShareAccountDataSerializer {
final Number collectionClientId = (Number) collection.get("client_id");
if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.client.mismatch",
.failWithCodeAndMessage("share.advance.collection.client.mismatch",
"Voucher " + label + " belongs to a different member.");
}
@@ -871,13 +896,13 @@ public class ShareAccountDataSerializer {
final String status = (String) collection.get("status");
if (!"UNAPPLIED".equals(status)) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.not.unapplied",
.failWithCodeAndMessage("share.advance.collection.not.unapplied",
"Voucher " + label + " is no longer available — it reads " + status + ".");
}
if (isReservedByAnotherTranche(advanceCollectionId)) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.already.reserved",
.failWithCodeAndMessage("share.advance.collection.already.reserved",
"Voucher " + label + " is already reserved against another pending share request.");
}
@@ -891,8 +916,21 @@ public class ShareAccountDataSerializer {
.multiply(BigDecimal.valueOf(sharesRequested));
if (receiptTotal.compareTo(trancheValue) < 0) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
.failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
.failWithCodeAndMessage("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
+ ", which does not cover the " + trancheValue + " value of the shares requested.");
} else if (receiptTotal.compareTo(trancheValue) > 0 && account.getSavingsAccount() == null) {
// An over-payment is settled by crediting the difference to the member's linked savings
// account (see AdditionalShareTrancheFundingService.postSharesIssued) -- with none linked,
// approval would have nowhere to put it. Caught here, at submit, rather than left to fail
// once the maker has already gone: short of a member paying the exact figure, this is
// refused up front instead of accepted and then unable to settle.
final BigDecimal excessAmount = receiptTotal.subtract(trancheValue);
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
.failWithCodeAndMessage("share.advance.collection.excess.no.savings",
"The vouchers selected come to " + receiptTotal + ", which is " + excessAmount + " more than the "
+ trancheValue + " value of the shares requested. The extra " + excessAmount
+ " must be credited to the member's savings account, but none is linked to this share account. "
+ "Link one, or collect the exact amount.");
}
}
@@ -1196,7 +1234,12 @@ public class ShareAccountDataSerializer {
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) {
// The minimum only protects an account that is staying open with a reduced holding -- it has
// nothing to say about one being emptied out entirely. A member redeeming every share they
// hold is exiting, not falling short of a floor; the account closes right behind this
// transaction (see closeShareAccountIfSharesZero), and there is no remaining balance left for
// "at least the minimum" to mean anything about.
if (minimumClientShares == null || remainingShares == 0 || remainingShares >= minimumClientShares) {
return;
}
final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency();
@@ -1301,17 +1344,20 @@ public class ShareAccountDataSerializer {
}
AppUser approvedUser = this.platformSecurityContext.authenticatedUser();
// 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.
// A close with shares still outstanding used to force-redeem them right here -- one native
// command, no maker-checker, liquidating the member's holding and crediting cash back to the
// GL Fineract's own bridge names, all in the single step of closing the account. That is
// exactly the governed Share Withdrawal flow's job: a clerk raises the redemption, a manager
// approves it, and it settles to wherever the clerk actually keyed -- the member's savings, a
// chosen GL, or Sundry. Refusing here instead of quietly redeeming is what makes closing an
// account with a balance impossible to do any way other than through that flow; the frontend
// already hid its own "Close" button for the same reason, but the command underneath was still
// reachable directly and this is what actually closes that gap.
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);
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname).value(closedDate)
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.with.shares.outstanding");
throw new PlatformApiDataValidationException(dataValidationErrors);
}
account.close(closedDate, approvedUser);
return actualChanges;
@@ -18,20 +18,18 @@
*/
package org.apache.fineract.portfolio.shareaccounts.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
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.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -41,11 +39,13 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransactio
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
/**
* Funds one additional-share tranche — moves the member's money into the Share Sundry holding
* account, where it waits for the tranche to be approved.
* Funds one additional-share tranche — records how the member is paying for it, and turns that
* money into share capital once the tranche is approved.
*
* <p>
* <b>Why this is separate from the account-level settlement resource.</b> {@code ShareAdvanceCollectionApiResource}
@@ -55,35 +55,33 @@ import org.springframework.stereotype.Component;
* valued and recorded per transaction, and leaves that account-level state alone.
*
* <p>
* <b>Where the money ends up.</b> Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
* the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by
* {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only
* gets the money into Sundry in the first place.
* <b>What is posted, and when.</b> Nothing is posted when a tranche is raised — a request that is never approved
* never touches anybody's money. {@code settle()} books the whole thing at approval, and the entry is the transfer the
* clerk actually keyed:
*
* <ul>
* <li><b>Cash</b> — already there. The cashier credited Sundry when the receipt was issued, so funding a cash tranche
* leaves no net journal entries: it reverses the stock APPLIED posting (which would credit Sundry a second time for
* money that came in once) and reserves the receipt against this tranche so it cannot be spent twice.</li>
* <li><b>Savings</b> — not there yet, so this debits the member:
* <li><b>Savings</b> — the member is debited and share capital issued, in one pair:
*
* <pre>
* Dr Savings Control (tranche value)
* Cr Share Sundry (tranche value)
* Cr Share Capital (tranche value)
* </pre>
*
* The member's balance drops on submit, not on approval. The withdrawal goes through Fineract's own savings withdrawal
* command so it reaches the member's passbook and statement exactly as a counter withdrawal would; that command posts
* its own Dr Savings Control / Cr Cash pair, which is wrong here because no cash left the branch, so those entries are
* reversed and replaced with the pair above.</li>
* Their balance is checked on submit so a request that cannot be paid for is refused up front, but the money only
* leaves when the shares are issued. The debit is written straight into their account rather than through Fineract's
* withdrawal command, which would post a cash pair for cash that never moves.</li>
* <li><b>Voucher</b> — the money is already in whichever account the cashier banked the receipt into, so that account
* is debited and share capital credited. Submitting only reserves the receipt against this tranche so it cannot be
* spent twice.</li>
* </ul>
*
* <p>
* Note that Fineract's stock accounting <i>also</i> posts an APPLIED leg of its own for a new purchase
* (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route
* here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those
* entries when the voucher was issued, and on the savings route the money came from the member's account rather than the
* till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once
* by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once.
* Fineract's stock accounting would post an APPLIED leg of its own when the tranche is raised
* (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now, and an APPROVED
* leg out of the product's mapped Sundry account. Neither matches either route here, so neither is made for a funded
* tranche (see {@code ShareAccountWritePlatformServiceJpaRepositoryImpl}). They used to be posted and reversed a moment
* later, which left dead rows in the ledger on every tranche and a Shares Reference leg that had to be explained to
* anyone reading the account.
*/
@Component
@RequiredArgsConstructor
@@ -94,22 +92,21 @@ public class AdditionalShareTrancheFundingService {
private static final int DEBIT = 2;
/** acc_product_mapping.financial_account_type for share products. */
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_SHARES_EQUITY = 4;
private static final int PRODUCT_TYPE_SHARES = 4;
/** What Fineract's own bridge prefixes a share transaction's journal entries with. */
private static final String SHARE_TXN_ID_PREFIX = "SH";
/** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
private static final int PRODUCT_TYPE_SAVINGS = 2;
private static final int FA_SAVINGS_CONTROL = 2;
private static final int SAVINGS_STATUS_ACTIVE = 300;
/** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
/**
* Funds a freshly raised tranche according to the route recorded on it.
@@ -125,20 +122,26 @@ public class AdditionalShareTrancheFundingService {
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
reserveAdvanceCollection(account, transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
debitSavingsIntoSundry(account, transaction);
validateSavingsFunding(account, transaction);
}
}
/**
* Settles a tranche's funding once the tranche has been approved and its value released from
* Share Sundry into share capital.
* Turns an approved tranche's funding into share capital, and closes the funding record behind it.
*
* No journal entries of its own — Fineract's APPROVED posting already moved the money. This only
* closes the funding record, so a voucher whose money is now share capital cannot be presented
* again: until this point the receipt was merely reserved, because approval was still refusable.
* Posts the whole entry itself rather than leaving it to the platform, because only this knows
* where the money actually is. Closing the record last means a voucher whose money is now share
* capital cannot be presented again: until this point the receipt was merely reserved, because
* approval was still refusable.
*/
public void settle(final ShareAccount account, final ShareAccountTransaction transaction) {
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
final String fundingSource = transaction.getFundingSource();
if (fundingSource == null) {
return;
}
postSharesIssued(account, transaction);
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
return;
}
// Every receipt behind the tranche, not just the lead one: a member's later instalments have
@@ -153,21 +156,170 @@ public class AdditionalShareTrancheFundingService {
}
/**
* Unwinds the funding of a tranche that was rejected, so the member's money does not sit in
* Share Sundry against shares that will never be issued.
* Issues the share capital for an approved tranche: the money leaves wherever it has been sitting
* and becomes equity.
*
* Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits
* Shares Reference, the till, which is only correct for the cash route. The routes part company
* here:
* <p>
* The credit is always Share Capital. What the debit is depends on where the money actually is,
* which is the whole point of posting this here rather than letting the platform do it:
*
* <ul>
* <li><b>Cash</b> — nothing to do. The money genuinely came in as cash, so returning it to the
* reference account is right, and releasing the receipt's reservation lets the clerk spend the
* voucher on another tranche or refund it at the counter.</li>
* <li><b>Savings</b> — the reference credit is wrong: no cash is going back over the counter.
* It is reversed and the member's savings are credited instead, which is where the money came
* from on submit.</li>
* <li><b>Savings-funded</b> - this service moved it into Share Sundry when the tranche was
* submitted, so Sundry is where it is and Sundry is what clears.</li>
* <li><b>Voucher-funded</b> - it never moved. It is still in whichever account the cashier banked
* the receipt into, read back from that receipt's own posting. The platform would have debited the
* product's mapped Sundry account instead, taking money out of an account that never received it
* and leaving the one that did holding the balance for ever.</li>
* </ul>
*
* <p>
* Receipts are drawn down in order, each capped at what it actually holds, so a tranche paid for
* in instalments clears each receipt for its own share. Anything the receipts do not cover falls
* back to Sundry, which keeps the entry balanced rather than silently posting a one-sided one.
*
* <p>
* Whatever a receipt holds beyond what the tranche needed — a member overpaying the lead receipt,
* or naming a further instalment that turns out not to be needed at all — is credited to their
* linked savings account, receipt by receipt, rather than left standing: every named receipt is
* marked APPLIED at the end of {@code settle()} regardless of whether the drawdown below actually
* reached it, so any part of one that this method does not move becomes untraceable money the
* moment that happens — still sitting in whatever GL the cashier banked it to, but with no
* UNAPPLIED receipt left pointing back at it.
*/
private void postSharesIssued(final ShareAccount account, final ShareAccountTransaction transaction) {
final BigDecimal amount = transaction.amount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
final Long productId = account.getShareProduct().getId();
final Long officeId = account.getOfficeId();
final String currencyCode = account.getCurrency().getCode();
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
final AppUser currentUser = this.context.authenticatedUser();
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final String txnId = SHARE_TXN_ID_PREFIX + transaction.getId();
final String accountNo = account.getAccountNumber();
if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(transaction.getFundingSource())) {
// The member pays for the shares at the moment they are issued, not when the request was
// raised: their balance is checked on submit but nothing is taken, so a tranche that is
// never approved never touches their account. One pair, Dr their savings / Cr share
// capital, which is exactly the transfer the clerk keyed.
final Long savingsAccountId = account.getSavingsAccount().getId();
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
"The Savings Control GL is not mapped for this savings account's product.");
}
final Long savingsTxnId = debitSavingsAccount(savingsAccountId, officeId, amount, entryDate);
transaction.updateSavingsDebitRequestId(savingsTxnId);
insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode,
"Additional shares paid from savings | " + accountNo);
insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode,
"Share capital issued for " + accountNo);
return;
}
BigDecimal remaining = amount;
BigDecimal excess = BigDecimal.ZERO;
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
// Every named receipt is visited, not just enough of them to cover the tranche: one left
// untouched here would still be marked APPLIED at the end of settle(), so this is the only
// chance to account for its money before that happens.
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
final Map<String, Object> coll = this.jdbcTemplate
.queryForList("SELECT amount, gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId)
.stream().findFirst().orElse(null);
if (coll == null) {
continue;
}
final BigDecimal held = (BigDecimal) coll.get("amount");
final BigDecimal take = held.min(remaining.max(BigDecimal.ZERO));
final BigDecimal leftover = held.subtract(take);
final Long receiptGl = receiptGlOf((String) coll.get("gl_transaction_id"), glSundry);
if (take.compareTo(BigDecimal.ZERO) > 0) {
insertEntry(officeId, receiptGl, DEBIT, take, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode, "Additional shares issued | " + accountNo);
remaining = remaining.subtract(take);
}
if (leftover.compareTo(BigDecimal.ZERO) > 0) {
// This receipt held more than the tranche needed. Its own GL still has to be
// cleared for the leftover -- the receipt is about to read APPLIED in full -- so
// that leftover is drawn down here too, and handed back below as a savings credit
// rather than left to vanish along with the receipt's UNAPPLIED status.
insertEntry(officeId, receiptGl, DEBIT, leftover, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode, "Excess on additional shares receipt refunded | " + accountNo);
excess = excess.add(leftover);
}
}
}
if (remaining.compareTo(BigDecimal.ZERO) > 0) {
insertEntry(officeId, glSundry, DEBIT, remaining, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode,
"Additional shares issued from Share Sundry | " + accountNo);
}
insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode,
"Share capital issued for " + accountNo);
if (excess.compareTo(BigDecimal.ZERO) > 0) {
// Validated at submit (see ShareAccountDataSerializer.validateAdvanceCollectionsForTranche):
// a tranche cannot be raised with receipts totalling more than its value unless a savings
// account is linked to receive the difference. Checked again here rather than trusted,
// since this also runs for a tranche raised before that guard existed.
if (account.getSavingsAccount() == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.excess.no.savings",
"This tranche's receipts total " + excess
+ " more than the shares cost, but no savings account is linked to credit it to.");
}
final Long savingsAccountId = account.getSavingsAccount().getId();
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
"The Savings Control GL is not mapped for this savings account's product.");
}
// Not recorded on transaction.savingsDebitRequestId: that field means the savings-funded
// route's own debit, a different transaction to a different end -- overloading it here for
// a cash-funded tranche's incidental credit would misname what it actually is.
creditSavingsAccount(savingsAccountId, officeId, excess, entryDate);
insertEntry(officeId, glSavingsControl, CREDIT, excess, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
currencyCode, "Excess on additional shares receipts credited to savings | " + accountNo);
}
}
/**
* The account a receipt's money is actually sitting in, read back from the posting that put it
* there - the credit leg of that receipt's own journal transaction. Falls back to the product's
* Sundry account for a receipt with no posting recorded against it.
*/
private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
if (glTransactionId == null || glTransactionId.isBlank()) {
return fallbackGlId;
}
final List<Long> rows = this.jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY id DESC LIMIT 1
""", Long.class, glTransactionId, CREDIT);
return rows.isEmpty() ? fallbackGlId : rows.get(0);
}
/**
* Unwinds the funding of a rejected tranche.
*
* <p>
* Nothing was ever debited on submit — {@link #postSharesIssued} is where the money actually
* moves, and that never ran, because approval is what would have called it — so there is nothing
* to give back. The only route with a submit-time side effect is cash, where the receipt was
* earmarked against this account; releasing that lets the clerk spend the voucher on another
* tranche or refund it at the counter. Savings has none, so it is a no-op here.
*/
public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) {
final String fundingSource = transaction.getFundingSource();
@@ -176,9 +328,9 @@ public class AdditionalShareTrancheFundingService {
}
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
releaseAdvanceCollection(transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
refundSavings(account, transaction);
}
// A savings-funded tranche has nothing to unwind: the member is only debited when the shares
// are issued, so a rejected one never took their money in the first place.
}
/**
@@ -192,85 +344,129 @@ public class AdditionalShareTrancheFundingService {
}
/**
* Returns a rejected tranche's money to the savings account it was taken from.
*/
private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) {
if (account.getSavingsAccount() == null) {
return;
}
final BigDecimal amount = transaction.amount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
final Long savingsAccountId = account.getSavingsAccount().getId();
final Long productId = account.getShareProduct().getId();
final Long officeId = account.getOfficeId();
final String currencyCode = account.getCurrency().getCode();
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
"The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final String note = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber();
// Fineract's own deposit command, so the refund reaches the member's passbook and statement
// as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in
// over the counter — so the cash debit is replaced by a Sundry debit below.
final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
// Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the
// till, so that credit is reversed; the Sundry debit it raised alongside is correct and
// stands.
reverseStockReferenceCredit(transaction.getId(), productId, officeId);
final AppUser currentUser = this.context.authenticatedUser();
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHTRREF-" + transaction.getId();
insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
"Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber());
}
/**
* Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this
* tranche, leaving its Sundry debit standing.
*/
private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) {
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT);
}
/**
* Reverses this tranche's own unreversed entries on one GL account and one side.
* Takes the tranche value out of the member's savings account as a book transfer: the transaction
* row and the balance, written directly, with no journal entry of its own.
*
* Matched on {@code share_transaction_id} rather than the {@code SH<id>} transaction_id string,
* because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a
* single GL and side is what keeps each reversal to the posting it is meant to undo.
* <p>
* Fineract's withdrawal command would post its own Dr Savings Control / Cr Cash pair, and no cash
* leaves the branch here - the money becomes share capital. That pair used to be posted and then
* reversed a moment later, leaving two dead rows in the ledger on every tranche. The caller books
* the real pair itself; this only moves the member's own balance.
*
* <p>
* The passbook and statement are unaffected: both read {@code m_savings_account_transaction},
* which is what this writes. Interest recalculation, lien and overdraft checks and business events
* are the trade-off, as on the disbursement register's own book transfers.
*/
private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) {
this.jdbcTemplate.update("""
UPDATE acc_gl_journal_entry SET reversed = true
WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false
""", shareTransactionId, glAccountId, officeId, typeEnum);
private Long debitSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
final LocalDate entryDate) {
final Map<String, Object> acct = this.jdbcTemplate
.queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
.findFirst().orElse(null);
if (acct == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing",
"The linked savings account " + savingsAccountId + " no longer exists.");
}
if (((Number) acct.get("status_enum")).intValue() != SAVINGS_STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active",
"The linked savings account is not active.");
}
final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived")
: BigDecimal.ZERO;
if (balance.compareTo(amount) < 0) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
"The linked savings account holds " + balance + ", which is less than the " + amount + " these shares cost.");
}
final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final long userId = this.context.authenticatedUser().getId();
final KeyHolder kh = new GeneratedKeyHolder();
this.jdbcTemplate.update(con -> {
// transaction_type_enum 2 = withdrawal.
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ "running_balance_derived, is_lien_transaction, "
+ "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ "VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
ps.setLong(1, savingsAccountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo);
ps.setBigDecimal(7, balance.subtract(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = total_withdrawals_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
payload.addProperty("note", note);
/**
* Credits the member's savings account as a book transfer, with no journal entry of its own — the
* counterpart to {@link #debitSavingsAccount} for the one case that runs the other way: a cash-funded
* tranche whose receipts added up to more than it cost, where the difference has nowhere else to go
* but back to the member.
*
* <p>
* Written directly for the same reason as the debit side: Fineract's deposit command would post its
* own Dr Cash / Cr Savings Control pair, and no cash is arriving at the counter here — the money was
* already in Share Sundry (or wherever the receipt's own GL was), and this is only moving the
* member's own balance to reflect it. No balance/status checks beyond existence: crediting an
* account can never leave it short of anything, unlike a debit.
*/
private Long creditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
final LocalDate entryDate) {
final Map<String, Object> acct = this.jdbcTemplate
.queryForList("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
.findFirst().orElse(null);
if (acct == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing",
"The linked savings account " + savingsAccountId + " no longer exists.");
}
final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived")
: BigDecimal.ZERO;
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId();
final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final long userId = this.context.authenticatedUser().getId();
final KeyHolder kh = new GeneratedKeyHolder();
this.jdbcTemplate.update(con -> {
// transaction_type_enum 1 = deposit.
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ "running_balance_derived, is_lien_transaction, "
+ "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
ps.setLong(1, savingsAccountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo);
ps.setBigDecimal(7, balance.add(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
+ "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/**
@@ -288,12 +484,10 @@ public class AdditionalShareTrancheFundingService {
"A cash-funded share request must name the voucher the money was received on.");
}
// Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new
// purchase. On this route the cashier already posted exactly that pair when the voucher was
// issued, so leaving it would count the member's money twice — Sundry credited 200 against a
// 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed:
// submitting a cash-funded tranche moves no money, it only earmarks the receipt.
reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId());
// No accounting at all on this route. The cashier already posted the money when the voucher
// was issued, and submitting a cash-funded tranche moves none of it -- it only earmarks that
// receipt for this account. Fineract's own APPLIED posting, which would have booked the same
// money a second time, is not made for a funded tranche (see applyAddtionalShares).
for (final Long collectionId : collectionIds) {
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(),
@@ -304,7 +498,7 @@ public class AdditionalShareTrancheFundingService {
/**
* Savings route: debits the member and credits Share Sundry.
*/
private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) {
private void validateSavingsFunding(final ShareAccount account, final ShareAccountTransaction transaction) {
if (account.getSavingsAccount() == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings",
"This share account has no linked savings account to debit.");
@@ -327,85 +521,10 @@ public class AdditionalShareTrancheFundingService {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
"Savings account " + savings.get("account_no") + " does not hold enough to cover this share request.");
}
final Long productId = account.getShareProduct().getId();
final Long officeId = account.getOfficeId();
final String currencyCode = account.getCurrency().getCode();
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
if (savingsControlGlOf(savingsAccountId) == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
"The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final String note = "Additional shares funded from savings | share account " + account.getAccountNumber();
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
// pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch.
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
// Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry,
// assuming cash over the counter. BOTH legs are reversed, not just the reference debit:
// leaving its Sundry credit standing and then posting the savings pair below would credit
// Sundry twice — 200 held against a 100 debit — and the surplus would never clear.
reverseStockAppliedPair(transaction.getId(), productId, officeId);
final AppUser currentUser = this.context.authenticatedUser();
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHTRSAV-" + transaction.getId();
insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
"Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber());
insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
"Additional shares held in Share Sundry pending approval | " + account.getAccountNumber());
transaction.updateSavingsDebitRequestId(savingsTxnId);
}
/**
* Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this
* tranche, leaving its Sundry credit standing.
*
* Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so
* the Sundry-to-Share-Capital pair raised later at approval is never caught by it.
*/
private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) {
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT);
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"),
CREDIT);
}
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
.build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId();
}
private Long resolveSettlementPaymentTypeId() {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
if (!rows.isEmpty()) {
return toLong(rows.get(0).get("id"));
}
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
if (fallback.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.payment.type",
"No payment types are configured. Add one before funding shares from savings.");
}
return toLong(fallback.get(0).get("id"));
}
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
@@ -440,19 +559,28 @@ public class AdditionalShareTrancheFundingService {
}
}
/**
* {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual
* entry normally would: these rows stand in for the ones the platform would have posted under the
* same {@code SH<id>} transaction id, so they have to be indistinguishable from them to everything
* that goes looking. Fineract's own {@code revertShareAccountJournalEntries} matches on the
* transaction id <i>and</i> {@code entity_type_enum = SHARES}, and would silently skip a row
* missing that column, leaving the accounting standing after the tranche behind it was reverted.
*/
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId,
final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
this.jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
@@ -24,6 +24,8 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -134,7 +136,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
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 transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT);
// Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in —
// a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each
// year's declaration went unclaimed and came back to profit.
@@ -178,13 +178,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
if (!changes.isEmpty()) {
this.shareAccountRepository.save(account);
}
// since we are reverting all journal entries we need to add journal
// entries for application request
// Modifying a pending application replaces its purchase transaction(s) with new ones of
// the same shape, and only ever runs before the account has been approved -- at which
// point nothing has been posted for it in the first place (see createShareAccount: no
// cash has been collected yet, so there is nothing to post until
// ShareAdvanceCollectionApiResource settles the real receipt, which requires the account
// to already be Active). revertShareAccountJournalEntries is still called as a safety net
// for any legacy account that predates that change and does carry a native posting for
// its original transaction; it is a no-op where there is nothing to find. What it must
// NOT do is post a fresh entry for the replacement transaction, which this used to do
// unconditionally -- booking Dr Shares Reference (i.e. "cash received") for an
// application nobody has paid for yet.
if (changes.containsKey("reversalIds")) {
ArrayList<Long> reversalIds = (ArrayList<Long>) changes.get("reversalIds");
this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate);
journalEntryWritePlatformService.createJournalEntriesForShares(
populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
changes.remove("reversalIds");
}
return new CommandProcessingResultBuilder() //
@@ -217,9 +224,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId());
Set<ShareAccountTransaction> transactions = new HashSet<>();
transactions.add(transaction);
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
// After the stock APPLIED posting, not before: the savings route reverses that
// posting's Shares Reference debit, which has to exist to be reversed.
// The stock APPLIED posting assumes cash came over the counter for these shares,
// and books Dr Shares Reference / Cr Share Sundry. A tranche funded through one of
// this fork's own routes did not arrive that way: the cash route's money was
// already taken at the till against a voucher, and the savings route debits the
// member's own account. Both used to let this post and then reverse it a moment
// later, leaving two dead rows behind on every tranche. Not posting it is the same
// outcome without the wreckage -- fund() below books what actually happened.
if (transaction.getFundingSource() == null) {
this.journalEntryWritePlatformService
.createJournalEntriesForShares(populateJournalEntries(account, transactions));
}
this.trancheFundingService.fund(account, transaction);
}
}
@@ -297,15 +312,12 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
this.noteRepository.save(note);
}
}
Set<ShareAccountTransaction> transactions = account.getShareAccountTransactions();
Set<ShareAccountTransaction> journalTransactions = new HashSet<>();
for (ShareAccountTransaction transaction : transactions) {
if (transaction.isActive() && !transaction.isChargeTransaction()) {
journalTransactions.add(transaction);
}
}
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions));
// Rejecting an application only ever happens before the account has been approved, at
// which point nothing has been posted for it -- createShareAccount posts nothing, and the
// real receipt is not booked until ShareAdvanceCollectionApiResource settles it, which
// requires the account to already be Active. Posting a REJECTED entry here used to credit
// Shares Reference (i.e. "cash returned") for cash that was never collected in the first
// place, because this application never reached the stage where any was.
return new CommandProcessingResultBuilder() //
.withCommandId(jsonCommand.commandId()) //
.withEntityId(accountId) //
@@ -412,9 +424,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
transactions.add(transaction);
totalSubscribedShares += transaction.getTotalShares();
}
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
// The APPROVED posting above has released the money from Share Sundry into
// share capital, so the funding behind it can now be closed off.
// The stock APPROVED posting releases the money into share capital out of the
// product's mapped Share Sundry account, whatever the money actually did. For a
// tranche funded through this fork's own routes that account is not always where
// it is: a voucher-funded one is sitting wherever the cashier banked the receipt.
// settle() books those itself, against the account the money is really in.
final Set<ShareAccountTransaction> stockPosted = new HashSet<>();
for (ShareAccountTransaction transaction : transactions) {
if (transaction.getFundingSource() == null) {
stockPosted.add(transaction);
}
}
if (!stockPosted.isEmpty()) {
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted));
}
for (ShareAccountTransaction transaction : transactions) {
this.trancheFundingService.settle(account, transaction);
}
@@ -451,9 +474,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
transactions.add(transaction);
}
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
// After the stock REJECTED posting, whose Shares Reference credit the savings
// route reverses in favour of refunding the member.
// The stock REJECTED posting unwinds the stock APPLIED one. A tranche funded
// through this fork's own routes never had that APPLIED posting made (see
// applyAddtionalShares), so posting its reversal here would book the undoing of
// something that was never booked. unwind() below returns the money the way it
// actually came in.
final Set<ShareAccountTransaction> stockPosted = new HashSet<>();
for (ShareAccountTransaction transaction : transactions) {
if (transaction.getFundingSource() == null) {
stockPosted.add(transaction);
}
}
if (!stockPosted.isEmpty()) {
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted));
}
for (ShareAccountTransaction transaction : transactions) {
this.trancheFundingService.unwind(account, transaction);
}
@@ -31,6 +31,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
@@ -304,7 +306,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
// back-dating it to 31 March files it months before it happened, where nobody reviewing the
// approval would find it. The financial year it belongs to is carried in the description.
postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")),
"DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLOSED),
java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
"FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #"
+ declaration.getId());
}
@@ -406,7 +409,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
final String transactionId = "DIVROUND" + dividendPayOutId;
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_ROUNDING);
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
// that will never be paid to a member, because no member's share divided into it evenly.
@@ -555,7 +558,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final AppUser currentUser = this.context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
final String transactionId = "DIVDECL" + dividendPayOutId;
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_DECLARED);
// The financial year is stamped into every dividend journal entry so the ledger itself can
// answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared
// across years, and without the year on the line the balances only ever give a running total.
@@ -602,7 +605,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
+ " (Reserve released to members)";
postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(),
"DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_APPROVED),
java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
}
/**
@@ -712,7 +716,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate());
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"),
"DIVFORF" + lockId, entryDate,
LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT), entryDate,
"FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS
+ " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate));
@@ -279,7 +279,8 @@ public class SponsorDDApiResource {
request.getChequeNumber(),
request.getChequeBankName(),
request.getCashierDepositRequestId(),
request.getDenominationData()
request.getDenominationData(),
request.getGlAccountId()
));
}
@@ -673,6 +674,9 @@ public class SponsorDDApiResource {
// that received this cash, credited to the DD Cash Suspense GL account.
private Long cashierDepositRequestId;
private String denominationData;
// GL only — the GL account the clerk chose to debit directly, instead of a member's own
// account, a cheque, or cash.
private Long glAccountId;
}
@lombok.Data
@@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService {
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
Long cashierDepositRequestId, String denominationData);
Long cashierDepositRequestId, String denominationData, Long glAccountId);
/**
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
@@ -60,6 +60,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT";
private static final String METHOD_CHEQUE = "CHEQUE";
private static final String METHOD_CASH = "CASH";
// A clerk debits a GL account directly (an institutional/holding account) instead of a member's
// own account, a cheque, or cash over the counter -- no client, no balance check, no walk-in
// purchaser details.
private static final String METHOD_GL = "GL";
/** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */
private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST";
@@ -101,7 +105,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
final Long cashierDepositRequestId, final String denominationData) {
final Long cashierDepositRequestId, final String denominationData, final Long glAccountId) {
final AppUser currentUser = this.context.authenticatedUser();
@@ -177,6 +181,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, receiptComments, null);
cashReceiptVoucherPending = true;
}
} else if (METHOD_GL.equals(paymentMethod)) {
// No client, no walk-in purchaser -- an institutional draft funded straight from a GL
// account. Debiting the same account the draft is credited to (DD Payable) would be a
// self-cancelling entry with no real movement, the same bug class the Share GL-settlement
// check exists to catch.
if (glAccountId == null) {
throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance.");
}
final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue();
if (glAccountId.equals(ddPayableGlAccountId)) {
final Map<String, Object> ddPayable = this.voucherService.glAccount(ddPayableGlAccountId);
throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account ("
+ ddPayable.get("gl_code") + ") this draft is credited to — pick a different GL account to debit.");
}
} else {
validateClientExists(clientId);
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
@@ -200,8 +218,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data, gl_account_id) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, leafletId);
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
@@ -228,6 +246,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
ps.setString(25, denominationData);
if (glAccountId != null) ps.setLong(26, glAccountId); else ps.setNull(26, java.sql.Types.BIGINT);
return ps;
}, kh);
@@ -410,7 +429,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ").");
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId);
"SELECT d.id, d.status, d.gl_account_id, d.amount, d.txn_id, d.share_account_id, cv.code_value AS entry_type"
+ " FROM m_cashier_deposit_request d LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id WHERE d.id = ?",
cashierDepositRequestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId);
}
@@ -419,6 +440,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
throw new IllegalStateException(
"The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status"));
}
if (deposit.get("share_account_id") != null) {
throw new IllegalStateException("This cashier deposit request has already been used for a share application.");
}
// The counter tags what a GL receipt was actually collected for at save time -- a code value
// under GLEntryType (Admin > System > Manage Codes). See
// 0565_convert_entry_type_to_code_value.xml. "General" and a missing tag (rows predating the
// column) are still usable; only an explicit, different purpose blocks it here.
final Object entryType = deposit.get("entry_type");
if (entryType != null && !"General".equals(entryType) && !"DD Issue".equals(entryType)) {
throw new IllegalStateException(
"This cashier deposit request was recorded as " + entryType + ", not a Demand Draft issuance. Ask the cashier to "
+ "correct the entry type, or select a different deposit request.");
}
final Object depositGlAccountObj = deposit.get("gl_account_id");
final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null;
if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) {
@@ -441,8 +475,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private Long findJournalEntryIdForCashierDeposit(final Map<String, Object> depositRequest, final Long cashSuspenseGlAccountId) {
final String txnId = (String) depositRequest.get("txn_id");
if (txnId == null) return null;
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId);
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", txnId, cashSuspenseGlAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
}
@@ -534,6 +572,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
}
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue();
} else if (METHOD_GL.equals(paymentMethod)) {
// The clerk's own chosen GL account -- no withdrawal command, no linked member account;
// submitIssueRequest() already confirmed it exists and differs from DD Payable.
drGlAccountId = ((Number) request.get("gl_account_id")).longValue();
} else if (METHOD_DIVIDEND.equals(paymentMethod)) {
// Nothing is collected from the member — the society already owes them this money — so
// the draft is funded by settling the Dividend Payable liability instead of debiting a
@@ -1237,8 +1279,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) {
throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits.");
}
} else if (METHOD_GL.equals(paymentMethod)) {
if (clientId != null) {
throw new IllegalArgumentException("Do not provide a Client ID for a GL-funded DD issuance — it is not raised against a member.");
}
if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) {
throw new IllegalArgumentException("Do not provide an account or cheque details when funding a draft from a GL account.");
}
} else {
throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH.");
throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, CASH, or GL.");
}
}
@@ -1325,7 +1374,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
* Payable entry, posted via postGlOffsetEntry() right after this call, is visible.
*/
private void markWithdrawalGlEntriesReversed(final String transactionId) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId);
// Guarded by reversed = false: if transaction_id text is ever reused, this can only flip
// rows that are currently live, never re-touch an already-reversed unrelated transaction.
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
transactionId);
}
/** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */
@@ -1374,8 +1427,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
.withDescription("Held pending DD approval"));
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_CREDIT, lines);
// Every other maker-checker request that raises a voucher stores its own voucher_no back
// on itself, which is how "My Voucher"/"My Work" finds it. A DD request can raise up to
// three vouchers across its life (this intake receipt, an approval issuance, or a
// rejection refund), so the column holds whichever was raised most recently -- still far
// better than the total blank it was before, and this is the one that exists on every DD
// paid in cash, receipt or not.
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE,
issueRequestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
if (voucherNo != null) {
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1406,8 +1468,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
.withDescription("Cash refunded to purchaser"));
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_DEBIT, lines);
// See the same lines in raiseCashReceiptVoucher() above for why this is required.
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
if (voucherNo != null) {
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1467,8 +1533,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.withDescription("GST on DD charges"));
}
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_DEBIT, lines);
// See the same lines in raiseCashReceiptVoucher() above for why this is required.
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
if (voucherNo != null) {
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1592,14 +1662,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String transactionId = result != null ? result.getTransactionId() : null;
if (transactionId == null) return null;
// transactionId was just minted by the createJournalEntry command above, in this same
// method invocation - but scope every read/write below to the single most recently
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
if (linkedAccountId != null) {
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2",
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1)",
linkedAccountId, transactionId);
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId);
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", transactionId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
}
@@ -626,10 +626,16 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, "
+ "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount "
+ "from m_cashier_deposit_request cdr "
+ "left join m_code_value cv on cv.id = cdr.entry_type_cv_id "
+ "where cdr.status = 'APPROVED' "
+ "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) "
+ "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request "
+ "where cashier_deposit_request_id is not null) "
// Already spoken for by a share application, or tagged for something else at save
// time -- kept out of the picker rather than only rejected at submit, so the counter
// never even offers the wrong voucher. See 0565_convert_entry_type_to_code_value.xml.
+ "and cdr.share_account_id is null "
+ "and (cv.code_value IS NULL OR cv.code_value IN ('General', 'DD Issue')) "
+ "order by cdr.actioned_at desc";
return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData(
rs.getLong("id"),
@@ -748,9 +748,22 @@ public class VoucherVerificationApiResource {
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
UNION ALL
-- This table only ever stored the checker's id, never a name snapshot the
-- way every other register here does, so approver_name comes back null and
-- the outer query's COALESCE to the verification row's name is what fills it
-- in on this one register.
SELECT voucher_no, checker_id, CAST(NULL AS VARCHAR), checker_date
FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
),
-- Who submitted the request the voucher belongs to.
--
@@ -766,8 +779,14 @@ public class VoucherVerificationApiResource {
SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, maker_id, status FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
)
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
v.created_by_username, o.name AS branch_name,
@@ -917,6 +936,26 @@ public class VoucherVerificationApiResource {
}
}
/**
* The other side of a split transaction — GL entries a native platform command posted on its own,
* outside this voucher, for the same underlying event.
*
* Nothing splits that way today, so this returns empty for every voucher. Share redemption used to:
* approval posted Dr Share Capital / Cr Share Sundry through the native {@code redeemshares}
* command, and only the settlement that followed (Dr Sundry / Cr Savings Control) became a
* voucher, so a reader saw half an entry and no sign of where the money came from. A redemption
* now books as the single entry the clerk actually keyed — Dr Share Capital straight to its
* destination — and the voucher carries both of its sides, so there is no longer a second half to
* show. Returning those same rows here would print this voucher's own lines a second time under a
* banner saying they are not part of it.
*
* Kept rather than deleted because it is the seam a genuinely split transaction would hook into:
* one {@code if} branch keyed on referenceType, not a new endpoint or a new field on the response.
*/
private List<Map<String, Object>> relatedGlEntries(final String referenceType, final Object referenceId) {
return List.of();
}
// ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ────────────────────────
// A query parameter for the same reason as verify: a formatted voucher number contains
// slashes, which the strict firewall rejects even encoded, so it cannot be a path segment.
@@ -1000,6 +1039,7 @@ public class VoucherVerificationApiResource {
// existed, or repaired by hand in the database, should show as unbalanced rather than be
// presented as a valid slip.
out.put("balanced", totalDebit.compareTo(totalCredit) == 0);
out.put("relatedLines", relatedGlEntries((String) h.get("reference_type"), h.get("reference_id")));
return gson.toJson(out);
}
@@ -326,6 +326,8 @@ public class VoucherService {
final Map<String, Object> party = savingsPartyForTransaction(savingsTxnId);
final String partyName = (String) party.get("party_name");
final String accountNo = (String) party.get("account_no");
final Long savingsAccountId = party.get("savings_account_id") == null ? null
: ((Number) party.get("savings_account_id")).longValue();
final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue();
final List<VoucherLine> lines = new ArrayList<>();
@@ -335,13 +337,24 @@ public class VoucherService {
// The member's own side is named after the member; every other leg is named after
// its GL account, which is what a voucher shows for an income or expense head.
final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId);
final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name");
final String ref = isMemberSide ? accountNo : (String) row.get("gl_code");
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT.
final int typeEnum = ((Number) row.get("type_enum")).intValue();
VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount)
: VoucherLine.creditGl(glId, name, ref, amount);
final boolean isDebit = typeEnum == 2;
final VoucherLine line;
if (isMemberSide) {
// This is the member's own savings account leg, not a GL posting -- pairing the
// shared Savings Control GL id with the member's own account number on a debitGl/
// creditGl line mismatched the two; debitSavings/creditSavings name the account
// this actually is.
line = isDebit ? VoucherLine.debitSavings(savingsAccountId, partyName, accountNo, amount)
: VoucherLine.creditSavings(savingsAccountId, partyName, accountNo, amount);
} else {
final String name = (String) row.get("gl_name");
final String ref = (String) row.get("gl_code");
line = isDebit ? VoucherLine.debitGl(glId, name, ref, amount) : VoucherLine.creditGl(glId, name, ref, amount);
}
final String description = (String) row.get("description");
lines.add(description == null ? line : line.withDescription(description));
}
@@ -370,6 +383,7 @@ public class VoucherService {
private Map<String, Object> savingsPartyForTransaction(final Long savingsTxnId) {
return jdbcTemplate.queryForList("""
SELECT COALESCE(c.display_name, g.display_name) AS party_name,
sa.id AS savings_account_id,
sa.account_no,
apm.gl_account_id AS control_gl_id
FROM m_savings_account_transaction t
@@ -536,6 +550,29 @@ public class VoucherService {
.orElse(Map.of());
}
/**
* The GL account Fineract's native loan disbursement/repayment command actually posted the cash
* leg to, for a voucher's till side — looked up by the {@code loan_transaction_id} FK on {@code
* acc_gl_journal_entry} rather than assumed, since which GL that command credits (a disbursement)
* or debits (a repayment) depends on the transaction's own payment type via its Fund Source
* mapping, not a single fixed cash account. Empty rather than absent if the transaction posted no
* matching leg (a non-cash payment type routed elsewhere, or accounting off for this tenant) —
* callers show a generic label in that case rather than a wrong or missing one.
*/
public Map<String, Object> tillGlFromLoanTransaction(final Long loanTransactionId, final boolean wantCreditLeg) {
if (loanTransactionId == null) {
return Map.of();
}
// type_enum: 1 = credit, 2 = debit.
final int typeEnum = wantCreditLeg ? 1 : 2;
return jdbcTemplate.queryForList("""
SELECT g.id, g.name, g.gl_code FROM acc_gl_journal_entry je
JOIN acc_gl_account g ON g.id = je.account_id
WHERE je.loan_transaction_id = ? AND je.type_enum = ? AND je.reversed = false
LIMIT 1
""", loanTransactionId, typeEnum).stream().findFirst().orElse(Map.of());
}
/**
* The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or
* one whose accounts were never recorded — callers show what they have rather than treating an
@@ -85,4 +85,31 @@
<include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0548_sponsor_dd_revoke_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0549_aml_alert_single_row_per_transaction.xml" relativeToChangelogFile="true"/>
<include file="parts/0550_client_risk_change_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0546_board_resolution_register.xml" relativeToChangelogFile="true"/>
<include file="parts/0551_aml_rule_coverage_classification.xml" relativeToChangelogFile="true"/>
<include file="parts/0552_activate_automatable_aml_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0553_client_behaviour_risk_review.xml" relativeToChangelogFile="true"/>
<include file="parts/0554_aml_recurring_review_validity_and_tier.xml" relativeToChangelogFile="true"/>
<include file="parts/0555_aml_str_case_covered_alerts.xml" relativeToChangelogFile="true"/>
<include file="parts/0554_aml_alert_highest_severity.xml" relativeToChangelogFile="true"/>
<include file="parts/0555_low_severity_aml_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0556_aml_alert_rule_disposition.xml" relativeToChangelogFile="true"/>
<include file="parts/0557_aml_alert_raised_on_actual_time.xml" relativeToChangelogFile="true"/>
<include file="parts/0558_aml_alert_rule_directly_decided.xml" relativeToChangelogFile="true"/>
<include file="parts/0559_risk_policy_parameters.xml" relativeToChangelogFile="true"/>
<include file="parts/0560_client_kyc_risk_profile_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0561_add_client_behaviour_window_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0562_behaviour_window_distinct_months.xml" relativeToChangelogFile="true"/>
<!-- Same parallel-branch prefix overlap as noted above: 0551-0554 below repeat numbers already
used by the AML set, but the filenames differ, so the changeset identities stay distinct. -->
<include file="parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0563_ledger_txn_sequence.xml" relativeToChangelogFile="true"/>
<include file="parts/0564_add_entry_type_to_gl_requests.xml" relativeToChangelogFile="true"/>
<include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,129 @@
<?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.
-->
<!--
Register of Board Resolutions (BOR).
A co-operative society records every board decision as a numbered resolution in its minute book, and an auditor or
the Registrar asks to see that register. Until now the number existed only as free text typed into whichever screen
needed to quote it - Apply Shares and Approve Shares both ask for one - with nothing behind it to check against.
This table is that register. Each row is one resolution: the meeting it was passed at, who was present, what was
decided, and the number the society recorded it under.
resolution_number is the society's own, typed from the minute book rather than generated here: a co-operative
numbers its resolutions when it minutes them, so a number this system invented would not match the book it is meant
to mirror. financial_year and resolution_seq are kept alongside so a year's resolutions can be grouped and ordered
even when their numbers do not sort usefully.
-->
<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">
<changeSet author="fineract" id="0546-1">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_board_resolution"/></not>
</preConditions>
<createTable tableName="m_board_resolution">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="resolution_number" type="VARCHAR(50)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_board_resolution_number"/>
</column>
<column name="financial_year" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="resolution_seq" type="INT">
<constraints nullable="false"/>
</column>
<!-- The meeting. -->
<column name="meeting_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="meeting_type" type="VARCHAR(40)" defaultValue="BOARD_MEETING">
<constraints nullable="false"/>
</column>
<column name="meeting_place" type="VARCHAR(200)"/>
<column name="office_id" type="BIGINT"/>
<!-- What was decided. subject is the one-line description the register lists; resolution_text is the
wording as minuted, which is what actually carries authority. -->
<column name="subject" type="VARCHAR(300)">
<constraints nullable="false"/>
</column>
<column name="resolution_text" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="category" type="VARCHAR(60)"/>
<!-- Attendance and the vote. Quorum is recorded as counted at the meeting rather than derived, because
the bye-laws that set it vary by society and change over time. -->
<column name="total_directors" type="INT"/>
<column name="members_present" type="INT"/>
<column name="quorum_met" type="BOOLEAN" defaultValueBoolean="true"/>
<column name="votes_for" type="INT"/>
<column name="votes_against" type="INT"/>
<column name="votes_abstained" type="INT"/>
<!-- Who signed it off. -->
<column name="proposed_by" type="VARCHAR(150)"/>
<column name="seconded_by" type="VARCHAR(150)"/>
<column name="chairperson_name" type="VARCHAR(150)"/>
<!-- APPROVED once passed; DRAFT while being minuted; CANCELLED when a later meeting rescinds it.
Superseding rather than deleting is the point: a resolution that was acted on cannot be removed from
the register just because it was later reversed. -->
<column name="status" type="VARCHAR(20)" defaultValue="APPROVED">
<constraints nullable="false"/>
</column>
<column name="effective_from" type="DATE"/>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="created_by" type="BIGINT"/>
<column name="created_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="now()"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0546-2">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_board_resolution_year_seq"/></not>
</preConditions>
<!-- Serves the register's default listing, which is by year then entry order. -->
<createIndex tableName="m_board_resolution" indexName="idx_board_resolution_year_seq">
<column name="financial_year"/>
<column name="resolution_seq"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0546-3">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_board_resolution_meeting_date"/></not>
</preConditions>
<createIndex tableName="m_board_resolution" indexName="idx_board_resolution_meeting_date">
<column name="meeting_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- One transaction was raising one alert per rule it matched. A single 12,00,000 cash deposit
came back as two open alerts - the CTR and the new-account rule - and an officer had to
dispose of both to deal with one deposit. Worse, whitelisting one while escalating the other
left the same transaction recorded as simultaneously cleared and suspicious.
A transaction-monitoring system raises one alert per transaction, under the rule that most
warrants attention, and lists the others alongside it. That is what an analyst works from
and what a regulator expects to see in the case file: one decision per transaction. -->
<changeSet id="0549-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_alert" columnName="also_matched_rules"/></not>
</preConditions>
<comment>
The other rules the same transaction matched. Kept so nothing is lost when the alert is
reduced to one row - the analyst can still see that a deposit was both reportable and on
a new account, and the case file shows every rule that fired.
</comment>
<addColumn tableName="m_aml_alert">
<column name="also_matched_rules" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
<changeSet id="0549-2" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_aml_alert_txn"/></not>
</preConditions>
<comment>
Read on every evaluation to find an alert already raised for this transaction, so a
re-run does not raise a second one.
</comment>
<createIndex tableName="m_aml_alert" indexName="idx_aml_alert_txn">
<column name="transaction_type"/>
<column name="transaction_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A customer's KYC risk category decides how often they are re-KYC'd and what their AML
thresholds are, so moving one is a decision about how closely the bank watches them. It
was only changeable by editing the KYC record directly - one officer, no second pair of
eyes, and no record of why.
The Risk Review Report already works out which customers are rated below what their
behaviour warrants. This lets the reviewer raise that change from the report itself and
sends it for approval, so the rating moves only once a checker agrees. -->
<changeSet id="0550-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_risk_change_request"/></not>
</preConditions>
<createTable tableName="m_client_risk_change_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Recorded as it stood when the request was raised, so an approval that lands after
the rating moved for some other reason can be seen to have been overtaken. -->
<column name="current_category" type="INT"/>
<column name="requested_category" type="INT">
<constraints nullable="false"/>
</column>
<column name="reason" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="checker_id" type="BIGINT"/>
<column name="checker_date" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
</createTable>
</changeSet>
<changeSet id="0550-2" author="CBS">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="fk_risk_change_client"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_client_risk_change_request" baseColumnNames="client_id"
constraintName="fk_risk_change_client" referencedTableName="m_client" referencedColumnNames="id"/>
</changeSet>
<changeSet id="0550-3" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_risk_change_status"/></not>
</preConditions>
<comment>The approval queue reads by status; the report reads by client.</comment>
<createIndex tableName="m_client_risk_change_request" indexName="idx_risk_change_status">
<column name="status"/>
</createIndex>
</changeSet>
<changeSet id="0550-4" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_risk_change_client"/></not>
</preConditions>
<createIndex tableName="m_client_risk_change_request" indexName="idx_risk_change_client">
<column name="client_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A GL settlement (settlement_mode = 'GL') credits whichever GL account the clerk picked on
the Fund Transfer's CR row, instead of the member's linked savings account: this column
records which one, so approve() knows where to post and the voucher can name it correctly. -->
<changeSet author="fineract" id="0551-1-add-settlement-gl-account">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_gl_account_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_gl_account_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,186 @@
<?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.
-->
<!--
Says, for each of the 221 AML scenarios in the catalog, how the bank actually covers it.
Until now the catalog was a flat list and 207 of those scenarios had nothing behind them - no transaction rule, no
review step, nothing. They read on screen exactly like the 14 that were live, which is the worst of both worlds: an
inspector sees 221 scenarios "monitored", and an officer has no way to tell which ones the system is actually
watching.
A real AML function never claims to automate everything. It classifies:
AUTOMATED the engine evaluates it on every transaction
MANUAL_REVIEW no rule can decide it - an officer judges it, so it belongs on a review checklist
("customer could not explain source of funds", "adverse media report")
NOT_APPLICABLE the bank does not do this business at all, so the scenario cannot arise
(trade finance, foreign remittance, credit cards, lockers - none exist in this CBS)
PLANNED automatable in principle, but needs data the engine is not yet given
Recording the reason alongside is what makes this defensible. "Not monitored" invites a finding; "not applicable,
because this bank has no trade finance book" answers it.
-->
<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">
<changeSet author="fineract" id="0551-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_risk_rule" columnName="coverage_status"/></not>
</preConditions>
<addColumn tableName="m_client_risk_rule">
<column name="coverage_status" type="VARCHAR(20)"/>
<column name="coverage_note" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
<!-- AUTOMATED: everything that already has a transaction rule behind it. -->
<changeSet author="fineract" id="0551-2">
<sql>
UPDATE m_client_risk_rule r
SET coverage_status = 'AUTOMATED',
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
WHERE rule_kind = 'AML'
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id);
</sql>
</changeSet>
<!--
NOT_APPLICABLE: business this bank does not transact.
A co-operative bank running this CBS has no trade finance book, no forex or cross-border remittance, no credit
card issuance and no locker module. The scenarios written for those cannot arise, and leaving them looking
unmonitored misrepresents the gap. Should the bank take on any of these lines, the classification is revisited
rather than the scenario being re-added.
-->
<changeSet author="fineract" id="0551-3">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No trade finance or import/export business is transacted on this CBS, so this scenario cannot arise.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Trade Based Money Laundering%' OR rule_name LIKE '%Overseas Direct Investment%');
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No cross-border remittance or foreign exchange business is transacted on this CBS.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Afghan Drug Business%'
OR rule_name LIKE '%cross border%' OR rule_name LIKE '%Cross border%'
OR rule_name LIKE '%foreign remittance%' OR rule_name LIKE '%foreign inward%'
OR rule_name LIKE '%outward remittance%' OR rule_name LIKE '%Outward foreign%'
OR rule_name LIKE '%another country%' OR rule_name LIKE '%tax havens%');
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No credit card is issued on this CBS.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Credit Card Monitoring%' OR rule_name LIKE '%credit card%');
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No safe deposit locker module exists on this CBS.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND rule_name LIKE '%Locker%';
</sql>
</changeSet>
<!--
MANUAL_REVIEW: scenarios that turn on a judgement no rule can make.
Whether a customer "could not explain the source of funds", or whether a media report is adverse, is decided by
the officer in front of them. These are not failures of automation - they are the part of AML that is meant to
be human, and RBI's Master Direction describes them as matters for the principal officer's assessment.
-->
<changeSet author="fineract" id="0551-4">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'MANUAL_REVIEW',
coverage_note = 'Turns on an officer''s judgement at the counter or during periodic review; carried on the AML review checklist.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Customer Due Diligence%'
OR rule_name LIKE 'Name Screening%'
OR rule_name LIKE '%Shell Companies%'
OR rule_name LIKE '%adverse media%' OR rule_name LIKE '%Adverse media%'
OR rule_name LIKE '%could not explain%'
OR rule_name LIKE '%does not reside%'
OR rule_name LIKE '%Complaint received%'
OR rule_name LIKE '%Mutilated%'
OR rule_name LIKE 'FICN%'
OR rule_name LIKE 'Terrorist Financing%'
OR rule_name LIKE 'Non Customer Monitoring%'
OR rule_name LIKE 'Additional RFIs Rule%'
OR rule_name LIKE '%Risk Categorization%');
</sql>
</changeSet>
<!--
PLANNED: automatable, but the engine is not yet given what it would need to decide them.
Counterparty identity (many-to-one), the account's dormancy state before the transaction, the turnover the
customer declared at KYC, and the channel a transaction arrived on are all absent from AmlTransactionContext.
Named here so the gap is visible and costed rather than discovered at an inspection.
-->
<changeSet author="fineract" id="0551-5">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Needs counterparty identity on the transaction context - not yet captured.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE '%Many to One%' OR rule_name LIKE '%One to Many%'
OR rule_name LIKE '%common phone%' OR rule_name LIKE '%joint holder%'
OR rule_name LIKE '%customers working together%'
OR rule_name LIKE '%Multiple accounts under the same name%'
OR rule_name LIKE '%unrelated parties%'
OR rule_name LIKE '%single Indian party%'
OR rule_name LIKE '%same remitter%'
OR rule_name LIKE '%several accounts%');
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Needs the customer''s declared turnover compared against actual activity - not yet computed.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE '%inconsistent with Profile%' OR rule_name LIKE '%inconsistent with client profile%'
OR rule_name LIKE 'Sudden %' OR rule_name LIKE '%surge in activity%'
OR rule_name LIKE '%High Net worth%');
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Needs the transaction channel (ATM, UPI, NEFT, RTGS, POS) on the context - not yet captured.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE '%ATM%' OR rule_name LIKE '%card usage%'
OR rule_name LIKE '%Non Home Branch%' OR rule_name LIKE '%multiple branches%');
</sql>
</changeSet>
<!--
Anything still unclassified is automatable with what the engine already has - a threshold, a direction, a window
and a subject. 0552 gives each of these a transaction rule; this marks them so the two migrations cannot drift.
-->
<changeSet author="fineract" id="0551-6">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'AUTOMATED',
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,387 @@
<?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.
-->
<!--
Gives a transaction rule to every catalog scenario the engine can already decide.
0551 classified the catalog; this acts on the AUTOMATED half of it. Each row below is a scenario that was described
in the catalog and never evaluated, expressed in the terms the engine understands - a type, a threshold, a
direction, a window, and who the rule is about.
Thresholds follow the reporting limits Indian banks work to: ten lakh is the CTR line, fifty thousand the PAN
quoting line under Rule 114B, and two lakh the cash acceptance ceiling under section 269ST. Where RBI's wording
gives no figure, the number is the one a co-operative bank of this size would set, and every one of them is
editable from the AML Rules Master afterwards - the point is that the scenario is evaluated at all, not that this
file guessed the bank's exact appetite.
Windows and counts are stated for pattern rules because a pattern needs both: three or more transactions inside a
band, not one large one. That distinction is what the engine's structuring and velocity paths test, and it is why a
single large deposit no longer reports as "splitting deposits below fifty thousand".
-->
<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">
<changeSet author="fineract" id="0552-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_DEPOSIT_DAY'</sqlCheck>
</preConditions>
<sql>
-- Helper: each INSERT links back to its catalog scenario by name, so the rule and the scenario it
-- implements stay connected and the register can show which is which.
-- ── Daily cash, by direction ────────────────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_DEPOSIT_DAY', 'High value cash deposits in a day', 'SAVINGS', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash deposits in a day' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_DAY', 'High value cash withdrawals in a day', 'SAVINGS', 1000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash withdrawals in a day' LIMIT 1;
-- Large cash debit. Separate from LARGE_CASH_TXN_25L, which is direction-agnostic: this is the catalog's
-- own withdrawal-side scenario and carries the lower 269ST-informed figure.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'LARGE_CASH_TXN_DEBIT', 'Large cash transaction (Debit)', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Large cash transaction(Debit)' LIMIT 1;
-- ── Monthly cash aggregates ─────────────────────────────────────────────────────────────────
-- STRUCTURING rather than THRESHOLD: the concern is a month's worth of deposits summing past the CTR
-- line, which is only a pattern if there were several of them.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_DEPOSIT_MONTH', 'High Value Cash Deposits in a month', 'SAVINGS', 1000000, 'STRUCTURING',
30, 3, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Deposits in a month' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_MONTH', 'High Value Cash Withdrawals in a month', 'SAVINGS', 1000000, 'STRUCTURING',
30, 3, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Withdrawals in a month' LIMIT 1;
-- ── Non-cash, daily and monthly ─────────────────────────────────────────────────────────────
-- cash_only false: these are deliberately the transfer/cheque counterparts of the rules above, which is
-- why the catalog lists them separately.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_DAY', 'High Value Non-Cash Deposits in a day', 'ALL', 2000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Non- Cash Deposits in a day' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_DAY', 'High Value non-Cash Withdrawal in a day', 'ALL', 2000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value non- Cash Withdrawal in a day' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_MONTH', 'High Value non-cash Deposits in a month', 'ALL', 2000000, 'STRUCTURING',
30, 3, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value non-cash Deposits in a month' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_MONTH', 'High value non-cash withdrawals in a month', 'ALL', 2000000, 'STRUCTURING',
30, 3, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value non-cash withdrawals in a month' LIMIT 1;
-- ── Credit entries and current accounts ─────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CREDIT_ENTRY_THRESHOLD', 'Threshold Limit in Credit entry', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Threshold Limit in Credit entry' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CURRENT_ACCOUNT_HIGH_VALUE', 'High Value Transactions in current account', 'CURRENT', 2500000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions in current account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CURRENT_ACCOUNT_UNUSUAL_ACTIVITY', 'Unusually high activity in Current Account', 'CURRENT', 5000000, 'VELOCITY',
30, 10, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Unusually high activity in Current Account(CA)' LIMIT 1;
-- ── New accounts ────────────────────────────────────────────────────────────────────────────
-- NEW_ACCOUNT scope keeps these off long-standing customers; the engine reads the account's opening date
-- from the context rather than inferring it from transaction history.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_HIGH_VALUE_TXN', 'High Value transactions in a new Account', 'ALL', 500000, 'THRESHOLD',
30, NULL, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value transactions in a new Account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_HIGH_ACTIVITY', 'High activity in a new account', 'ALL', 200000, 'VELOCITY',
30, 5, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'High activity in a new account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_TXN_COUNT_MONTHLY', 'Number of Transactions in newly opened Accounts, monthly', 'ALL', 100000, 'VELOCITY',
30, 10, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'Number of Transactions in newly opened Accounts, monthly' LIMIT 1;
-- ── Subject-scoped: who the customer is ─────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'REAL_ESTATE_ACCOUNT_MONITORING', 'Monitor accounts of parties in real estate', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Monitor accounts of parties%occupation related to Real Estate' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'REAL_ESTATE_CASH_HIGH_VALUE', 'High value cash transactions - real estate agents and dealers', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions pertaining to accounts of real estate agents and dealers' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'PRECIOUS_METAL_DEALER_CASH', 'High value cash transactions by dealer in precious metal or stone', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions by dealer in precious metal or stone' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NPO_CASH_HIGH_VALUE', 'High value cash transactions in NPOs', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, true, 'CHARITABLE_ORG', id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions in NPOs' LIMIT 1;
-- NTR territory: FIU-IND expects a non-profit receipt return at ten lakh, so the figure is not the
-- bank's to choose.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NGO_RECEIPT_OF_FUNDS', 'Registered NGOs - receipt of funds in account', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, 'CHARITABLE_ORG', id, true
FROM m_client_risk_rule WHERE rule_name = 'Account Monitoring - Reg. NGOs - Receipt of funds in account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NPO_ONWARD_DISBURSAL', 'NPOs receiving funds and transferring to multiple beneficiaries', 'ALL', 500000, 'VELOCITY',
30, 5, 'DEBIT', false, 'CHARITABLE_ORG', id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'NPOs or charities receiving funds%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_RISK_CUSTOMER_HIGH_VALUE', 'High Value transactions by high risk customers', 'ALL', 500000, 'THRESHOLD',
NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value transactions by high risk customers' LIMIT 1;
-- Staff accounts. Left inactive and with no subject scope: the resolver knows PEP, charitable, high-risk
-- business and new-account only, so a STAFF scope would fall through to "matches everything" and report
-- this against every customer in the bank. Configured now, switched on when staff can be identified.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'STAFF_ACCOUNT_MONITORING', 'Monitoring accounts of Staff', 'ALL', 200000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'Monitoring accounts of Staff' LIMIT 1;
-- ── Loans and deposits ──────────────────────────────────────────────────────────────────────
-- Two lakh is section 269ST's ceiling on cash accepted in a single transaction, which is the line a cash
-- loan repayment is measured against.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'LOAN_CASH_REPAYMENT_WINDOW', 'Repayment of loan in cash over a window', 'LOAN', 200000, 'STRUCTURING',
90, 3, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Repayment of loan in cash greater than%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'FDR_PREMATURE_CLOSURE_LARGE', 'Premature closure of large FDR through PO/DD', 'DEPOSIT', 1000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Premature closure of large FDR through PO/DD' LIMIT 1;
-- ── Instruments and inward funds ────────────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'DD_DEPOSIT_IMMEDIATE_WITHDRAWAL', 'Multiple deposit of MCs/DDs followed by immediate withdrawal', 'ALL', 500000, 'VELOCITY',
7, 3, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Multiple deposit of Mcs/DDs%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NUMEROUS_CREDIT_SLIPS', 'Numerous small credit slips totalling a substantial amount', 'SAVINGS', 500000, 'STRUCTURING',
7, 5, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Depositing cash by means of numerous credit slip%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'INWARD_REMITTANCE_HIGH_VALUE', 'High value inward remittance', 'TRANSFER', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value inward remittance' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'INWARD_REMITTANCE_NEW_ACCOUNT', 'Inward remittance in a new account', 'TRANSFER', 200000, 'THRESHOLD',
30, NULL, 'CREDIT', false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'Inward remittance in a new account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEFT_HIGH_VALUE', 'Monitoring NEFT transactions', 'TRANSFER', 1000000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Monitoring NEFT Transactions' LIMIT 1;
-- ── Frequency ───────────────────────────────────────────────────────────────────────────────
-- The catalog lists this scenario twice; FREQUENT_LOW_CASH_WITHDRAWAL already covers the first. This is
-- the monthly counterpart, over a longer window.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, min_limit_amount, max_limit_amount, source_client_risk_rule_id, is_active)
SELECT 'FREQUENT_LOW_CASH_WITHDRAWAL_MONTH', 'Frequent low cash withdrawals (monthly)', 'SAVINGS', 300000, 'VELOCITY',
30, 8, 'DEBIT', true, NULL, 1, 50000, id, true
FROM m_client_risk_rule WHERE rule_name = 'Frequent low cash withdrawals'
AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = m_client_risk_rule.id)
LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CHEQUE_LEAVES_HIGH_USAGE', 'High number of cheque leaves', 'ALL', 100000, 'VELOCITY',
30, 20, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High number of cheque leaves' LIMIT 1;
-- Round-figure transfers, a laundering tell the catalog describes in foreign currency but which reads
-- the same in rupees. Banded to the round lakh so ordinary amounts do not match.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, min_limit_amount, source_client_risk_rule_id, is_active)
SELECT 'ROUND_FIGURE_TRANSFERS', 'Transfers in round multiples, repeated', 'TRANSFER', 1000000, 'VELOCITY',
30, 5, NULL, false, NULL, 100000, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Amounts of money transfer carried out%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'INWARD_THEN_IMMEDIATE_OUTFLOW', 'Inward remittance followed by immediate withdrawal or transfer', 'ALL', 500000, 'VELOCITY',
2, 3, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Inward remittance followed by immediate%' LIMIT 1;
</sql>
</changeSet>
<!--
PAN-quoting scenarios. Rule 114B requires a PAN on cash deposits of fifty thousand or more, so these are about
the absence of one - a condition the engine cannot yet read from the transaction context.
Left inactive rather than omitted: the rule exists with the right threshold and wording, so activating it is a
switch once the context carries the customer's PAN status, and until then the register shows exactly why it is
off rather than the scenario silently missing.
-->
<changeSet author="fineract" id="0552-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_WITHOUT_PAN'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_WITHOUT_PAN', 'High value cash transactions in accounts without PAN', 'ALL', 50000, 'THRESHOLD',
NULL, NULL, NULL, true, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'High value Cash transactions in accounts without PAN' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_WITHOUT_PAN', 'High value non-cash transactions in accounts without PAN', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'high value non cash transactions in accounts without PAN' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_TXN_WITHOUT_PAN_ID', 'High Value Transactions without PAN ID', 'ALL', 50000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions without PAN ID' LIMIT 1;
</sql>
</changeSet>
<!--
The rules seeded above are now backed; mark their scenarios AUTOMATED so the register reflects what the engine
actually evaluates. The PAN rules stay PLANNED - they exist but are switched off, and calling them automated
would be the same overstatement this whole exercise is correcting.
-->
<changeSet author="fineract" id="0552-3">
<sql>
UPDATE m_client_risk_rule r
SET coverage_status = 'AUTOMATED',
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
WHERE r.rule_kind = 'AML'
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t
WHERE t.source_client_risk_rule_id = r.id AND t.is_active = true);
UPDATE m_client_risk_rule r
SET coverage_status = 'PLANNED',
coverage_note = 'Rule is configured with its threshold but switched off, because the engine cannot yet '
|| 'identify this scenario''s subject from the transaction context.'
WHERE r.rule_kind = 'AML'
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t
WHERE t.source_client_risk_rule_id = r.id AND t.is_active = false)
AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t2
WHERE t2.source_client_risk_rule_id = r.id AND t2.is_active = true);
-- Anything still unclassified after 0551 and this file is automatable in principle but has no rule yet.
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Automatable, but no transaction rule has been configured for it yet.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Every other request type that raises a voucher (m_clerk_withdrawal_request,
m_clerk_closure_request, m_clerk_disbursement_request, m_cashier_deposit_request) stores the
voucher_no it was given back on itself, so VoucherVerificationApiResource.myWork() and the
Received/Payment/Journal registers can find it by joining on this table. Share withdrawal
never got this column, so raiseShareWithdrawalVoucher()'s vouchers existed in
m_voucher/m_voucher_line but were invisible everywhere a register looks a voucher up by its
owning request: "My Voucher"/"My Work" silently dropped them, and Payment totals came up
short by exactly what had been correctly posted and disbursed. -->
<changeSet author="fineract" id="0552-1-add-voucher-no">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="voucher_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Same gap as 0552 for m_clerk_share_withdrawal_request, found by the same audit: both of
these maker-checker request tables raise a voucher (DividendClaimRequestApiResource,
SponsorDDIssueRequestWritePlatformServiceImpl) but never stored voucher_no back on
themselves, so VoucherVerificationApiResource.myWork() could never find it: a manager who
approved a dividend cash payout or a DD issuance saw it nowhere in "My Voucher"/"My Work",
and the branch's Payment total came up short by exactly what had been paid out. -->
<changeSet author="fineract" id="0553-1-add-voucher-no-dividend-claim">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_share_dividend_claim_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_share_dividend_claim_request">
<column name="voucher_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0553-2-add-voucher-no-sponsor-dd">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="voucher_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Risk ratings were being suggested off a single alerted transaction. A High-risk customer who
bought something small enough to trip nothing came back as "lower to Low", and a Low-risk
customer moving large sums repeatedly came back as nothing at all, because the report only
ever saw the transactions a rule had already fired on - never the customer's actual
behaviour.
A rating is about the customer, not about one transaction. This records what each customer
actually did over a period - every transaction, not only the alerted ones - and compares it
with the income they declared at onboarding. That comparison is what a periodic KYC review
is, and it is the only thing that can honestly say a rating should move.
Raising and lowering are deliberately not symmetric. A rating goes up on evidence and comes
down only on a clean period: getting an increase wrong costs a customer some extra
monitoring, getting a decrease wrong takes the monitoring off someone who needed it. -->
<changeSet id="0553-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_behaviour_window"/></not>
</preConditions>
<comment>
One row per customer per closed observation window. Written by the review job, never by
a transaction - a single transaction is an input to a window, not a window of its own.
</comment>
<createTable tableName="m_client_behaviour_window">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="window_from" type="DATE">
<constraints nullable="false"/>
</column>
<column name="window_to" type="DATE">
<constraints nullable="false"/>
</column>
<!-- Every transaction in the window, not only the ones that tripped a rule. -->
<column name="txn_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="credit_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="debit_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="largest_txn" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- Cash is watched separately: the same turnover in cash and by transfer are not the
same risk, because cash carries no counterparty trail. -->
<column name="cash_txn_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="cash_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- Transactions at or above the CTR limit, which are reportable in their own right. -->
<column name="large_txn_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- What the customer said they earn, copied in as it stood: a later edit to the client
record must not silently rewrite a window that was already assessed. -->
<column name="declared_annual_income" type="DECIMAL(19,6)"/>
<column name="declared_annual_turnover" type="DECIMAL(19,6)"/>
<!-- Window credits, annualised, over declared income. Above 1 means the customer moved
more than they said they earn. -->
<column name="income_ratio" type="DECIMAL(19,6)"/>
<column name="alert_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="str_case_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="risk_category_at_review" type="INT"/>
<!-- UP / DOWN / HOLD, with the sentence explaining it. -->
<column name="suggested_direction" type="VARCHAR(10)"/>
<column name="suggested_category" type="INT"/>
<column name="suggestion_reason" type="VARCHAR(600)"/>
<column name="computed_on" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="0553-2" author="CBS">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="fk_behaviour_window_client"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_client_behaviour_window" baseColumnNames="client_id"
constraintName="fk_behaviour_window_client" referencedTableName="m_client" referencedColumnNames="id"/>
</changeSet>
<changeSet id="0553-3" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_behaviour_window_client"/></not>
</preConditions>
<comment>Read per customer, newest first, by both the report and the next run of the job.</comment>
<createIndex tableName="m_client_behaviour_window" indexName="idx_behaviour_window_client">
<column name="client_id"/>
<column name="window_to"/>
</createIndex>
</changeSet>
<changeSet id="0553-4" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_behaviour_window_direction"/></not>
</preConditions>
<comment>The review worklist reads the customers whose rating the last window says should move.</comment>
<createIndex tableName="m_client_behaviour_window" indexName="idx_behaviour_window_direction">
<column name="suggested_direction"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A clerk funding a Demand Draft by debiting a GL account directly (payment_method = 'GL'),
instead of a member's savings/current account, a cheque or cash: this column records which
GL account approveIssueRequest() debits, credited against the sponsor's own DD Payable GL. -->
<changeSet author="fineract" id="0554-1-add-gl-account-to-sponsor-dd-issue-request">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="gl_account_id"/>
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="gl_account_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- An alert reports the severity of the rule it was raised under, and that is the only rule
the screen can see. Since one transaction now raises one alert under whichever rule most
warrants attention, a high-severity rule can end up in also_matched_rules - not because it
was less serious, but because a statutory CTR outranked it.
An officer sweeping up low and medium alerts for one batch decision would then be offered
a transaction that had tripped a high-severity rule, with nothing on the row to say so.
This records the highest severity across every rule the transaction matched, so the screen
can hold back exactly those. -->
<changeSet id="0554-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_alert" columnName="highest_severity"/></not>
</preConditions>
<comment>
1 Low, 2 Medium, 3 High — the worst band among all rules this transaction matched, not
only the governing one. Null where no matched rule carried a severity at all, which is
how the CTR rules arrive: they are returns the bank owes, not graded suspicions.
</comment>
<addColumn tableName="m_aml_alert">
<column name="highest_severity" type="INT"/>
</addColumn>
</changeSet>
<changeSet id="0554-2" author="CBS">
<comment>
Backfill from the governing rule for alerts raised before this column existed. Their
also_matched rules are recorded as codes rather than ids, so the governing rule's own
severity is the most that can honestly be said about them.
</comment>
<sql>
UPDATE m_aml_alert a
SET highest_severity = (
SELECT cr.risk_category
FROM m_aml_transaction_rule r
JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id
WHERE r.id = a.rule_id)
WHERE a.highest_severity IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,61 @@
<?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.
-->
<!--
Gives a recurring-alert review a life of its own, and records how severe the customer's repetition had become when
it was taken.
A review used to hold the customer out of the queue only until their alert count rose by one. A customer whose
ordinary business trips a rule every few days therefore came back the day after being reviewed, and the officer
re-read the same explanation they had written themselves. Nothing about the response changed however many times
this happened, so a customer surfacing for the fortieth time was handled exactly like one surfacing for the first.
valid_until the review stands until this date; the customer stays out of the queue meanwhile unless their
alert count grows past the escalation tier's own trigger
escalation_tier what the count had reached when reviewed - ROUTINE, ELEVATED, SENIOR or MANDATORY_STR - so the
history reads as a progression rather than a flat list of identical reviews
alerts_actioned how many of the customer's open alerts this review actually disposed of, which is what separates
a review that did something from one that only recorded an opinion
-->
<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">
<changeSet author="fineract" id="0554-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_recurring_review" columnName="valid_until"/></not>
</preConditions>
<addColumn tableName="m_aml_recurring_review">
<column name="valid_until" type="DATE"/>
<column name="escalation_tier" type="VARCHAR(20)"/>
<column name="alerts_actioned" type="INT" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
<!--
Existing reviews keep behaving as they did: no validity date means the old "any new alert brings them back"
rule still applies to them, rather than back-dating a window they were never taken under.
-->
<changeSet author="fineract" id="0554-2">
<sql>
UPDATE m_aml_recurring_review SET escalation_tier = 'ROUTINE' WHERE escalation_tier IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,109 @@
<?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.
-->
<!--
Lets one STR case cover several alerts.
A case has always pointed at exactly one alert, which is right when an officer escalates a single transaction they
find suspicious. It is wrong for a case raised off a customer's repeated behaviour: escalating a recurring-activity
review opened one case per open alert, so a customer reviewed at four alerts put four cases in front of the
Principal Officer, each describing one transaction in a pattern that only means anything taken together. Reviewed
again at nine, they would put five more.
That is also not how the report itself works. An STR to FIU-IND describes suspicious *activity* - the pattern, the
customer, and the transactions that make it up - so the officer investigating needs them in one place, not split
across cases that each hold a fragment.
m_aml_str_case.alert_id stays as it is: it remains the alert the case was opened from, which keeps every existing
case, query and screen working unchanged. This table records the rest of what a case covers, so a case opened from
one transaction reads exactly as it does today, and one opened from a pattern carries all of it.
-->
<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">
<changeSet author="fineract" id="0555-1">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_aml_str_case_alert"/></not>
</preConditions>
<createTable tableName="m_aml_str_case_alert">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="str_case_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="alert_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="added_on_utc" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="0555-2">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="FK_str_case_alert_case"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_aml_str_case_alert" baseColumnNames="str_case_id"
constraintName="FK_str_case_alert_case"
referencedTableName="m_aml_str_case" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_aml_str_case_alert" baseColumnNames="alert_id"
constraintName="FK_str_case_alert_alert"
referencedTableName="m_aml_alert" referencedColumnNames="id"/>
</changeSet>
<!--
One alert belongs to at most one case. Without this a second escalation of the same transaction could attach it
to a second case, and the same transaction would reach FIU-IND twice under two references.
-->
<changeSet author="fineract" id="0555-3">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="uq_str_case_alert"/></not>
</preConditions>
<createIndex tableName="m_aml_str_case_alert" indexName="uq_str_case_alert" unique="true">
<column name="alert_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0555-4">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_str_case_alert_case"/></not>
</preConditions>
<createIndex tableName="m_aml_str_case_alert" indexName="idx_str_case_alert_case">
<column name="str_case_id"/>
</createIndex>
</changeSet>
<!--
Existing cases each cover the one alert they were opened from, so the link table describes them too and nothing
has to ask "which kind of case is this" when reading coverage.
-->
<changeSet author="fineract" id="0555-5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_str_case_alert</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_str_case_alert (str_case_id, alert_id, added_on_utc)
SELECT id, alert_id, now() FROM m_aml_str_case;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,109 @@
<?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.
-->
<!--
Gives the catalog a low-severity band for transaction behaviour.
The catalog held 19 Low entries and not one of them could raise an alert: every Low row is rule_kind 'KYC', which
describes who a customer is - a student, a farmer, a government employee - and never what a transaction looks like.
All 221 AML-kind rules sat in Medium or High. So the engine could not produce a Low alert at all, the Low Risk tile
on the Flagged Transactions screen was permanently nil, and an officer had only two settings: ignore, or treat as a
suspicion worth disposing of.
That gap matters beyond the empty tile. Monitoring needs somewhere to put activity that is worth recording and is
not worth an officer's suspicion - the first slightly-large withdrawal on a quiet account, a run of small cash
credits, a round-sum payment. Real CBS monitoring logs these to build a behavioural picture over time; they become
evidence when something else happens on the same account, and they are noise if each one demands a decision. Low is
that place, and it is what makes batch disposition useful: many small observations, one decision.
Bands sit strictly below the Medium rules they shadow, so an amount belongs to exactly one severity. Cash movement
of fifty thousand and above in a repeating pattern is Medium (FREQUENT_LOW_CASH_*, band 50k-2L, three in seven
days); a single such transaction is not covered there at all, and that single transaction is what MODERATE_CASH_*
records. The frequency rule below stops at 49,999 so it cannot collide with the Medium band.
-->
<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">
<!-- The catalog scenarios. Written as AML-kind Low rows so they can source a transaction rule, which the
existing KYC-kind Low rows cannot. -->
<changeSet author="fineract" id="0555-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client_risk_rule WHERE rule_kind = 'AML' AND risk_category = 1</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_client_risk_rule (rule_name, risk_category, rule_kind, reason, order_position, aml_check_status, coverage_status)
VALUES
('Moderate cash deposit on a savings account', 1, 'AML',
'Cash deposit at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 601, 'FLAGGED', 'AUTOMATED'),
('Moderate cash withdrawal on a savings account', 1, 'AML',
'Cash withdrawal at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 602, 'FLAGGED', 'AUTOMATED'),
('Repeated small cash deposits in a week', 1, 'AML',
'Several small cash deposits inside a week - ordinary for a trader, worth recording for anyone else', 603, 'FLAGGED', 'AUTOMATED'),
('First sizeable transaction on a new account', 1, 'AML',
'A recently opened account moving money for the first time - normal after a salary or a festival, and the point at which monitoring should start watching', 604, 'FLAGGED', 'AUTOMATED');
</sql>
</changeSet>
<!-- The transaction rules that implement them. Bands stop below the Medium rules so no amount is graded twice. -->
<changeSet author="fineract" id="0555-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MODERATE_CASH_DEPOSIT'</sqlCheck>
</preConditions>
<sql>
-- Rs 50,000 to Rs 2,00,000 in cash, one transaction. Above the PAN quoting line under Rule 114B, below
-- the Rs 10,00,000 CTR line. The Medium rule over the same band asks for three inside a week; a single
-- one was previously recorded nowhere.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'MODERATE_CASH_DEPOSIT', 'Moderate cash deposit', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD',
NULL, NULL, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Moderate cash deposit on a savings account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'MODERATE_CASH_WITHDRAWAL', 'Moderate cash withdrawal', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD',
NULL, NULL, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Moderate cash withdrawal on a savings account' LIMIT 1;
-- Four or more cash credits each under Rs 50,000 inside a week, totalling Rs 1,00,000 or more.
-- Deliberately capped at 49,999 so it cannot overlap the Medium band that begins at 50,000: this is
-- frequency in small change, which is a shopkeeper ordinary week and only matters if it persists.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'REPEATED_SMALL_CASH_DEPOSIT', 'Repeated small cash deposits in a week', 'SAVINGS', 100000, 1, 49999,
'VELOCITY', 7, 4, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Repeated small cash deposits in a week' LIMIT 1;
-- Rs 25,000 up to Rs 5,00,000 on an account opened within the last 180 days. NEW_ACCOUNT is the scope the
-- resolver already understands; the ceiling is where NEW_ACCOUNT_LARGE_TXN (Medium) takes over, so the
-- two never both fire.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_FIRST_ACTIVITY', 'First sizeable transaction on a new account', 'SAVINGS', 25000,
25000, 499999, 'THRESHOLD', NULL, NULL, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'First sizeable transaction on a new account' LIMIT 1;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,120 @@
<?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.
-->
<!--
Lets an officer dispose of the individual rules a transaction matched, not only the transaction as a whole.
One transaction raises one alert, under the rule that most warrants attention, and the rest are recorded alongside it.
That is right for reporting - a transaction is one case - but it left the officer with one verdict covering everything
that fired. A deposit can trip six rules where five are explainable by the customer's declared business and the sixth
is not; recording "whitelisted" against all six loses exactly the observation that mattered, and recording
"escalated" against all six sends five findings to the STR that do not belong there.
This table holds a decision per rule. The alert keeps its own status - it is still the case, and it is what the CTR
and STR registers read - but it is now derived from the rules beneath it: every rule cleared means the alert is
whitelisted, any rule escalated means the alert is escalated, and anything in between leaves the alert open with
part of it decided.
-->
<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">
<changeSet author="fineract" id="0556-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_aml_alert_rule"/>
</not>
</preConditions>
<createTable tableName="m_aml_alert_rule">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="alert_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- The code rather than a foreign key to m_aml_transaction_rule: the alert is a record of what was
detected at the time, and editing or retiring a rule afterwards must not rewrite that history. -->
<column name="rule_code" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<!-- Copied at the time of the decision for the same reason. A rule re-banded later does not change what
the officer was looking at when they decided. -->
<column name="risk_category" type="INT"/>
<!-- True for the rule the alert itself was raised under. -->
<column name="is_governing" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<!-- NULL until decided; then WHITELIST or ESCALATE. -->
<column name="disposition" type="VARCHAR(20)"/>
<column name="reviewed_by" type="BIGINT"/>
<column name="reviewed_on" type="TIMESTAMP WITHOUT TIME ZONE"/>
<column name="review_notes" type="VARCHAR(1000)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_aml_alert_rule" baseColumnNames="alert_id"
constraintName="fk_aml_alert_rule_alert" referencedTableName="m_aml_alert"
referencedColumnNames="id" onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="m_aml_alert_rule" baseColumnNames="reviewed_by"
constraintName="fk_aml_alert_rule_reviewer" referencedTableName="m_appuser"
referencedColumnNames="id"/>
<!-- A rule appears once per alert: the same rule cannot be both cleared and escalated on one transaction. -->
<addUniqueConstraint tableName="m_aml_alert_rule" columnNames="alert_id, rule_code"
constraintName="uq_aml_alert_rule"/>
<createIndex tableName="m_aml_alert_rule" indexName="idx_aml_alert_rule_alert">
<column name="alert_id"/>
</createIndex>
</changeSet>
<!-- Backfill: every alert already raised gets a row per rule it matched, so the expanded pane is populated for
history as well as for alerts raised from here on. An alert already decided has that decision copied down to
each of its rules - it was made about all of them, and leaving them blank would show a closed alert as
undecided. -->
<changeSet author="fineract" id="0556-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_alert_rule</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by,
reviewed_on, review_notes)
SELECT a.id, r.rule_code, cr.risk_category, true, a.disposition, a.reviewed_by, a.reviewed_on, a.review_notes
FROM m_aml_alert a
JOIN m_aml_transaction_rule r ON r.id = a.rule_id
LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id;
INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by,
reviewed_on, review_notes)
SELECT a.id, code.rule_code, cr.risk_category, false, a.disposition, a.reviewed_by, a.reviewed_on,
a.review_notes
FROM m_aml_alert a
CROSS JOIN LATERAL unnest(string_to_array(a.also_matched_rules, ',')) AS raw(rule_code)
CROSS JOIN LATERAL (SELECT trim(raw.rule_code) AS rule_code) AS code
LEFT JOIN m_aml_transaction_rule r ON r.rule_code = code.rule_code
LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id
WHERE a.also_matched_rules IS NOT NULL
AND a.also_matched_rules &lt;&gt; ''
AND trim(raw.rule_code) &lt;&gt; ''
ON CONFLICT (alert_id, rule_code) DO NOTHING;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?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.
-->
<!--
Gives already-raised alerts the time they were actually raised.
raised_on was being set from the transaction's value date - a LocalDate widened with atStartOfDay() - so every alert
ever raised sat at midnight. The feed is ordered by it and the screen prints it, so alerts raised minutes apart were
indistinguishable and every row read "00:00". The engine now records the moment the alert is raised; this repairs the
rows written before that.
created_on_utc is the auditing timestamp written when the row was inserted, which is the time being recovered. Only
rows still sitting exactly at midnight are touched, so anything already correct is left alone and the changeset is
safe to re-run.
-->
<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">
<changeSet author="fineract" id="0557-1">
<sql>
UPDATE m_aml_alert
SET raised_on = created_on_utc
WHERE created_on_utc IS NOT NULL
AND raised_on = date_trunc('day', raised_on);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,58 @@
<?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.
-->
<!--
Separates the rule an officer actually decided from the rules that were carried with it.
Marking one rule suspicious escalates the whole transaction, and every other rule on it is closed under that same
escalation - the transaction is one case, so nothing can be left open behind a filed STR. But the record then read as
though the officer had examined and condemned all of them, which is not what happened and is not what an audit of the
decision should show. On a transaction that tripped six rules, knowing which one the officer put their name to is the
whole substance of the finding.
This flag carries that: true where the officer named the rule, false where the system closed it to keep the case
consistent. Existing rows are backfilled on the same principle - a rule closed by a whole-alert decision was decided
by the officer, because there they acted on the transaction as a whole.
-->
<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">
<changeSet author="fineract" id="0558-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_aml_alert_rule" columnName="directly_decided"/>
</not>
</preConditions>
<addColumn tableName="m_aml_alert_rule">
<column name="directly_decided" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- Everything decided before this column existed was decided through a whole-alert action, which is a direct
decision on every rule under it. -->
<changeSet author="fineract" id="0558-2">
<sql>
UPDATE m_aml_alert_rule SET directly_decided = true WHERE disposition IS NOT NULL;
</sql>
</changeSet>
</databaseChangeLog>

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