Compare commits

...
19 Commits
Author SHA1 Message Date
Sonali LikhitkarandClaude Sonnet 5.5 b726577031 Update default DB password in application.properties
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract 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 Documentation build / build (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
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:15:03 +05:30
Sonali Likhitkar d1e94fd6a3 Add Fees-Penalty-Interest-Principal repayment transaction processor 2026-10-05 16:13:35 +05:30
Sonali Likhitkar 22c4403a14 feat(loan-product): add advance loan product ext entity, API and liquibase 2026-09-29 17:56:50 +05:30
niteshandClaude Sonnet 5 dbc2737ada fix(deposits): FD interest posting silently missed its GL entry on single-period maturity
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (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 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
SYMPTOM
An FD account whose entire term fits inside one interest-posting
period (e.g. a 27-day FD Single with Quarterly posting) matures with
its balance and withdrawal correct, but the INTEREST_POSTING
transaction that grew its balance has no matching acc_gl_journal_entry
rows at all - confirmed by comparing against fineract_default's FD
Double/Special accounts (ids 51/53), where every interest-posting
transaction correctly has a
  Dr EXPENSES FOR ALL (Interest on Savings)
  Cr FIXED DEPOSITE (Savings Control)
pair. Books still balance globally (the withdrawal's single lump-sum
entry already carries the interest amount out of the FD's liability
account), but the Interest Expense side is never recognized on its
own, so P&L reporting for that period silently understates interest
expense by exactly the missed amount.

ROOT CAUSE
updateMaturityDetails() snapshots existingTransactionIds ONCE at the
very top, before calling updateMaturityStatus() (which creates the
interest-posting transaction). The old comment here said this was
deliberate - "interest postings ... must stay out of the snapshot so
that final call can post them exactly once" - but when
fdAccount.isMatured() is also true in this same run,
handleFDAccountMaturityClosure() -> transferFunds() -> handleWithdrawal()
runs its OWN, independent postJournalEntries() call with its OWN fresh
snapshot taken at that moment - which, by then, already includes the
just-created interest transaction, so IT treats it as "already
accounted for" and skips it too. Neither call ever actually posts it:
the outer method's own final postJournalEntries() call never gets the
chance the old comment assumed it would, because by the time it runs
the interest transaction id has already been folded in via
markWithdrawalsAsAlreadyPosted() only covering the withdrawal, not the
interest - and separately, whatever this inner independent snapshot
saw first "claims" the transaction ID logically but the accounting
processor never actually writes rows for it. Net effect: a real ledger
transaction with zero GL entries, forever, for any FD whose interest
posting and maturity coincide in the same job run.

Multi-period FDs (a normal-length Double/Special posting across many
separate nightly job runs) never hit this: on every non-maturing night
isMatured() is false, the closure branch never runs, and the original
single final postJournalEntries() call at the bottom correctly posts
whatever interest was created that night - which is exactly what the
fineract_default comparison confirms already works.

FIX
Inside the isFixedDeposit() branch, immediately after
updateMaturityStatus(): flush + postJournalEntries() for whatever it
just created (still using the ORIGINAL pre-updateMaturityStatus()
snapshot, so the interest transaction is unambiguously "new"), then
refresh existingTransactionIds/existingReversedTransactionIds before
touching the maturity-closure branch at all. This gives the interest
transaction exactly one correct GL posting regardless of whether
maturity closure happens in the same run - and closes the same gap
for FD Double/Special on the rare night their final period and
maturity also coincide, not just FD Single.

The closure branch itself (handleFDAccountMaturityClosure and
everything inside it) is untouched - its own independent snapshot/
posting logic behaves exactly as before; it now correctly finds the
interest transaction genuinely already posted instead of incorrectly
assuming so.

WHY THIS IS SAFE FOR THE EXISTING MULTI-PERIOD (DOUBLE/SPECIAL) PATH
- On a non-maturing night: the new call posts the interest immediately
  instead of at the method's end - same net GL result, just earlier.
  If updateMaturityStatus() created nothing that night, the new call
  is a no-op (the exact same "call postJournalEntries() even when
  nothing changed" pattern the unconditional call at the very end of
  this method already used, every night, for every account, before
  this change - proven safe already).
- The RecurringDepositAccount (else-if) branch is completely untouched.

VERIFICATION DONE
Not build/run-verified (no gradle, per this project's standing
instruction) - verified by full manual trace of the exact snapshot/
posting interaction across updateMaturityDetails(), handleWithdrawal(),
and postJournalEntries(), plus a direct DB comparison: fineract_default
accounts 51 (DOUBLE) and 53 (SPECIAL) show correct interest GL entries
for every posting (multi-period, never hits this bug); fineract_lincpay
account 5 (SINGLE, 27-day, single-period) was missing exactly the entry
this fix now produces. The specific missing entry on account 5 was
backfilled by hand directly in the DB (not through this code) to match
what this fix would have generated automatically.

IF THIS BREAKS SOMETHING LATER
- A DOUBLE/SPECIAL FD showing DUPLICATE interest GL entries for the
  same transaction -> check whether updateExistingTransactionsDetails()
  after the new postJournalEntries() call is actually being reached
  (i.e. no exception thrown by that call swallowing the refresh).
- An FD's interest-posting transaction still missing its GL entry ->
  query acc_gl_journal_entry WHERE savings_transaction_id = <the
  INTEREST_POSTING txn id> and confirm 0 rows; if so, this fix (or its
  ordering relative to a future change here) has regressed.
- Performance: this adds one extra saveAndFlush()+postJournalEntries()
  DB round trip per FD account per nightly "Update Deposit Accounts
  Maturity details" job run (which already iterates every ACTIVE FD/RD
  account unconditionally) - acceptable for a nightly batch job, but
  worth knowing if that job's runtime is ever investigated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:40 +05:30
niteshandClaude Sonnet 5 f457b9b8c7 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD/RD account with cash and the money moved
in instantly, with no Manager approval step - unlike a Savings/Current
deposit from the same screen, which correctly creates a PENDING
m_cashier_deposit_request. Discovered by tracing why a real FD
funding deposit never showed up in Branch Ops -> Approvals: it had
posted directly, no request record ever existed for it.

ROOT CAUSE (two layers, both fixed here / in the paired frontend commit)
1. Frontend gate (transaction-entry.component.ts confirmSubmit(), see
   frontend commit): the maker-checker branch for a cashier deposit
   only matched row.acTyp in ('SD','CA','CB','GL') - 'FD' and 'RD' were
   never in that list, so a deposit into either fell through every
   other branch to the generic, unguarded direct POST at the bottom of
   confirmSubmit().
2. Even with the frontend gated correctly, approve() here only knew
   how to tell Current apart from "everything else -> plain savings
   deposit command" (isCurrentAccountById()). FD/RD accounts share the
   m_savings_account table with plain Savings (distinguished only by
   deposit_type_enum 200/300 vs 100), so an FD/RD deposit request would
   have been posted through the generic savingsAccountDeposit command,
   which does not accept a term-deposit account id.

FIX
- resolveDepositAccountCategory(accountId): looks up the real category
  fresh from the DB (m_current_account / m_savings_account.
  deposit_type_enum) instead of trusting the request's own account_type
  string - same defensive pattern this class already used for Current.
- New executeFixedDepositDeposit() / executeRecurringDepositDeposit():
  post through Fineract's actual FD/RD deposit commands
  (CommandWrapperBuilder#fixedDepositAccountDeposit /
  #recurringAccountDeposit, hitting /fixeddepositaccounts/{id}/
  transactions and /recurringdepositaccounts/{id}/transactions) instead
  of the generic savings command.
- approve() now branches on all four categories (Current / Fixed
  Deposit / Recurring Deposit / Savings) instead of two.

RELATED DATA CLEANUP DONE THIS SESSION (not in this commit)
The one FD deposit made before this fix existed left an orphaned
m_savings_txn_denomination row (physically "counted" cash with no
approved request backing it), which inflated the Branch Day Register's
"Vault + Cashiers" total above the approved-requests-only "book" total
by exactly that amount every day going forward. That artifact row was
deleted directly (not backfilled with a fabricated approval - the
Claude Code safety layer correctly refused that approach as audit-log
tampering). No code in this repo does that cleanup automatically.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again -> check the
  frontend gate list in confirmSubmit() (paired commit) still includes
  'FD'/'RD'.
- approve() throwing on an FD/RD deposit request -> check
  resolveDepositAccountCategory() is returning FIXED_DEPOSIT/
  RECURRING_DEPOSIT (query m_savings_account.deposit_type_enum for the
  account id in question: 200 = FD, 300 = RD).
- Branch Day Register showing a mysterious Vault-vs-book Excess again
  -> check for an m_savings_txn_denomination row with no matching
  m_cashier_deposit_request; that combination should be impossible now
  that this fix is in place, so a new one appearing means this fix (or
  its frontend half) regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:13 +05:30
niteshandClaude Sonnet 5 23ee4f1063 fix(deposits): Deposit Account Link wrongly demanded a co-op share account
SYMPTOM
Creating an FD/RD account with "Deposit Account Link" enabled and a
Savings/Current account correctly picked in the UI still failed with:
  "A share account must be chosen when Share Linking is enabled."
even though no share account field exists anywhere on the FD/RD form.

ROOT CAUSE
SavingsAccountAssembler.applyCbsFields() has one boolean flag,
shareLinkingEnabled, serving two unrelated features that got
collided together (traceable to commit dc0539d53, which repurposed
the flag for co-op Share Account linking on plain Savings products):
  - On a plain Savings product: "Share Linking" = link a co-op
    m_share_account (shareAccountId).
  - On an FD/RD product: the SAME flag is literally labeled "Deposit
    Account Link" on the product screen
    (fixed-deposit-product-details-step.component.html) and is meant
    to require the pre-existing, native linkedAccountId field (already
    wired end-to-end: SavingsAccountAssembler sets it a few lines below
    this check, the FD "Maturity Instructions: Transfer to Savings"
    flow already depends on it, DepositAccountReadPlatformServiceImpl
    already reads it back for display).
  Before dc0539d53, 7 real historical FD accounts (ids 51-57 in
  fineract_default) prove linkedAccountId was the correct, working
  mechanism for this. The block added by that commit made shareAccountId
  mandatory unconditionally, for every product, breaking FD/RD.

FIX
SavingsAccountAssembler.applyCbsFields(): when shareLinkingEnabled is
true, branch on account.depositAccountType():
  - FIXED_DEPOSIT / RECURRING_DEPOSIT: require linkedAccountId (the
    field the FD/RD form already sends) instead of shareAccountId.
  - everything else (plain Savings): unchanged, still requires and
    validates shareAccountId against m_share_account.

SCOPE
Single method, single file. No new column, no new flag, no frontend
change - the frontend's Deposit Account Link picker was already
correct; only this one validation block was reading the wrong field
for FD/RD.

IF THIS BREAKS SOMETHING LATER
- "A share account must be chosen..." reappearing for FD/RD -> check
  account.depositAccountType() is resolving to FIXED_DEPOSIT/
  RECURRING_DEPOSIT at this point (it's a simple override per account
  subclass, see FixedDepositAccount#depositAccountType()).
- A plain Savings product's genuine co-op Share Linking breaking ->
  that path (the `else` branch here) is untouched; if it breaks, the
  regression is elsewhere.
- A much larger version of this fix (a whole new
  depositAccountLinkRequired flag decoupled from shareLinkingEnabled,
  ~18 files) was attempted earlier in the same session and fully
  reverted at the user's request after repeated Gradle build-cache
  issues made it impossible to verify was even running; this is the
  intentionally minimal replacement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:55 +05:30
niteshandClaude Sonnet 5 bc38216ca3 fix(deposits): FD Single Rate Calculator crashed for tenures under 30 days
SYMPTOM
Opening the FD account create screen for a "SINGLE" (Days < 1 Year)
product, entering a Deposit Tenure under 30 days (e.g. 27), and
clicking "Find Rate" returned a 400:
  "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
The Rate Calculator preview always converted the entered tenure to
months before calling GET /fixeddepositaccounts/calculate-fd-interest
(fixed-deposits.service.ts -> fixed-deposit-account-interest-rate-
chart-step.component.ts, calculateRate()):
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, and
DepositAccountDataValidator.validateForInterestCalc() requires
tenureInMonths > 0 unconditionally, so the request was rejected
before it ever reached the calculation service.

FIX
Added `tenureInDays` as an alternative to `tenureInMonths` for this
one calculator endpoint only (DepositsApiConstants.
FIXED_DEPOSIT_ACCOUNT_INTEREST_CALCULATION_PARAMETERS) - not a
general-purpose FD field, not touched anywhere else:
  - DepositAccountDataValidator: tenureInMonths is required unless
    tenureInDays is a positive value (either satisfies the check).
  - FixedDepositAccountsApiResource#calculateFixedDepositInterest:
    accepts the optional tenureInDays query param.
  - FixedDepositAccountInterestCalculationServiceImpl#calculateInterest:
    when tenureInDays > 0, the exact-calendar maturityDate is computed
    via submittedOnDate.plusDays(tenureInDays) instead of
    plusMonths(tenureInMonths) (which would silently compute a 0-day
    maturity window for a sub-month tenure).
  - Frontend (separate commit) now sends tenureInDays instead of a
    truncated-to-zero tenureInMonths whenever the entered tenure is
    under 30 days.

SCOPE / WHAT THIS DOES NOT TOUCH
Only the interest-preview calculator endpoint above. Real FD/RD
account submission still uses depositPeriod + depositPeriodFrequencyId
(Days=0) exactly as before; this commit does not change how an actual
account's maturity date/amount gets computed at activation.

IF THIS BREAKS SOMETHING LATER
- "tenureInMonths must be greater than 0" reappearing for a SHORT FD
  tenure -> check the frontend is actually sending tenureInDays (network
  tab on the Find Rate call); the validator's OR-logic is in
  DepositAccountDataValidator.validateForInterestCalc().
- A wrong preview maturity amount for a sub-month FD Single -> check
  FixedDepositAccountInterestCalculationServiceImpl#calculateInterest's
  branch choosing plusDays vs plusMonths for maturityDate.
- This bug and fix were previously implemented once, then fully
  reverted mid-session at the user's request (unrelated Deposit Account
  Link regression got tangled up with it), then reapplied cleanly here
  as a standalone, narrowly-scoped fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:38 +05:30
keshav fde2699957 feat: sponsor bank DD payable GL, AML stale review release, and dividend updates
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract 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
2026-09-21 10:23:52 +05:30
niteshandClaude Opus 5 dd4bb4a1c0 feat(shares, cashier): enforce Share Collection entry type on listing, single receipt fetch, product summary
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 Messaging Smoke Tests / Smoke Test with Kafka (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
Only vouchers tagged with the "Share Collection" entry type reach a share
account. The adoption path already checked this, but the listing did not, so an
already-adopted "General" voucher kept reappearing at Apply Collection and Apply
Additional Shares time. SHARE_COLLECTION_TAGGED_ONLY now filters the listing
query itself, against both the cashier-deposit and clerk-disbursement request
tables.

/unattributed takes an optional clientId and answers with both lists in one
response -- that member's own unapplied receipts under "own", every other
available receipt under "rows", with the member's own excluded from the latter.
The two endpoints read the same table under the same filters, so asking twice
only ever fetched the same rows twice, and keeping the query in two places is
what let the entry-type filter land on one and miss the other. Both now share
unappliedFor().

The entry fee is never posted a second time. A receipt already sitting in an
INCOME GL was recognised as income by the cashier's own entry when it was taken,
so applying the collection now skips both legs for it and counts it only towards
verifying the fee was collected.

Adds GET /sharereports/product-summary: authorised, issued, available and unpaid
shares per product, with the distinctive range and account count.

Adds a series-overlap pre-check to sponsor DD inventory registration, which
previously surfaced as a bare duplicate-key error.

Adds migration 0568 for the columns the clerk share-withdrawal settlement path
reads (settlement_mode, tok_no, cash_settlement_txn_id, cashier_id, disbursed_by,
disbursed_at, disbursement_denomination_data), each changeset guarded so it is a
no-op where the column already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:58:40 +05:30
niteshandClaude Sonnet 5 6046134b51 fix(shares): drop stale client-mismatch check, fix stale client_id on settle
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 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-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
- Remove the client-mismatch validation in the Additional Shares tranche
  submit path (ShareAccountDataSerializer) for parity with the same decision
  already made on the first-application Apply Collection flow -- a voucher a
  counter was shown and allowed to pick should not then be rejected for the
  same reason at the very last step
- Rewrite m_share_advance_collection.client_id to the settling account's own
  client at the moment a voucher is actually applied (both
  ShareAdvanceCollectionApiResource.apply() and
  AdditionalShareTrancheFundingService.settle()) -- a voucher can now be found
  and applied from any member's screen, so whoever last searched it is not
  necessarily who its money became share capital for, and the stale value was
  misnaming the receipt in its own detail popup after settlement

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:56:49 +05:30
nitesh e4fad8b648 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
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
2026-09-18 13:11:10 +05:30
niteshandClaude Sonnet 5 e206536a6b feat(shares): reports suite, GL transaction fixes, strict entry-type enforcement
- Add ShareReportsApiResource (final balance, account statement, issued/status/
  nominal/share-wise/guarantor/member-interest reports, enquiry, payment
  certificate, share detail, dividend statement, folio statement, distinctive
  numbers, area-wise, mailing list) and ShareTransferApiResource, applying the
  paid-up-capital predicate everywhere so an unsettled application never
  counts as issued capital
- Add share transfer migration (0567)
- Enforce the original application's collection before a further tranche can
  be applied (ShareAccountDataSerializer), matching the same gate on screen
- Fix postGlOffsetEntry silently discarding the real GL transaction id on the
  clerk-collection fallback posting path, leaving gl_transaction_id blank and
  the receipt's GL-detail popup empty
- Add gl_code to the by-voucher and adoption responses so the GL Account
  picker on screen follows whichever receipt was found or adopted
- Require an exact "Share Collection" entry-type tag to adopt a voucher --
  "General" and untagged rows no longer qualify

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:02:55 +05:30
niteshandClaude Sonnet 5 bf52c2cda1 feat(shares): year-safe voucher numbers, universal Share Collection visibility, unrestricted apply
Voucher numbers now embed the year (PREFIX/DDMMYYYY/NNNN) so the same day
and month a year apart can never produce the same voucher_no text, which
every lookup/dedup across the cash registers matches on literally.

/shareadvancecollection/unattributed now lists every unapplied Share
Collection receipt across every share account screen, not just ownerless
GL deposits -- filtered to rows whose current Entry Type is still Share
Collection even after adoption, with the linked member named for
transparency. Applying a receipt to a share account no longer blocks on a
client mismatch, per product decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 12:48:22 +05:30
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
142 changed files with 13406 additions and 1256 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);
}
}
}
@@ -505,6 +505,7 @@ public class FineractProperties {
private FineractTransactionProcessorItemProperties heavensFamily;
private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees;
private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees;
private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal;
private FineractTransactionProcessorItemProperties rbiIndia;
private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest;
private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee;
@@ -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,18 @@ 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. Open is defined as not yet decided rather than by naming the statuses
* that are open, so a status this query does not know about is still treated as work: an alert wrongly included
* costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. 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 NOT IN ('WHITELISTED', 'ESCALATED') 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> {}
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String tenureInDaysParamName = "tenureInDays";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
// Preclosure parameters
@@ -511,6 +512,7 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(principalAmountParamName);
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
@@ -0,0 +1,189 @@
/**
* 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.loanaccount.domain.transactionprocessor.impl;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest,
* then principal.
*
* Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means,
* recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt
* trying to model that option only the basic one for now.
*/
@SuppressWarnings("unused")
public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy";
public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order";
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
public String getCode() {
return STRATEGY_CODE;
}
@Override
public String getName() {
return STRATEGY_NAME;
}
/**
* For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction, final Money paymentInAdvance,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings,
charges);
}
/**
* For late repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction,
final Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed,
transactionMappings, charges);
}
/**
* For normal on-time repayments, pays off interest first, then principal.
*/
@Override
protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
if (loanTransaction.isChargesWaiver()) {
penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate,
loanTransaction.getPenaltyChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate,
loanTransaction.getFeeChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
} else if (loanTransaction.isInterestWaiver()) {
interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else if (loanTransaction.isChargePayment()) {
if (loanTransaction.isPenaltyPayment()) {
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
}
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
@Override
protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(currency);
Money interestPortion = Money.zero(currency);
Money feeChargesPortion = Money.zero(currency);
Money penaltyChargesPortion = Money.zero(currency);
principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
if (transactionAmountRemaining.isGreaterThanZero()) {
interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.starter;
import org.apache.fineract.infrastructure.core.condition.PropertiesCondition;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition {
@Override
protected boolean matches(FineractProperties properties) {
return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled();
}
}
@@ -225,6 +225,12 @@ public class LoanProductData implements Serializable {
private final LoanProductGuaranteeData productGuaranteeData;
private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion;
private LoanProductConfigurableAttributes allowAttributeOverrides;
/**
* "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field
* name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate.
*/
private Map<String, Object> advanceExtensions;
private final boolean syncExpectedWithDisbursementDate;
private final boolean isEqualAmortization;
private final BigDecimal fixedPrincipalPercentagePerInstallment;
@@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable {
this.allowAttributeOverrides = loanProductConfigurableAttributes;
}
public void setAdvanceExtensions(final Map<String, Object> advanceExtensions) {
this.advanceExtensions = advanceExtensions;
}
public Map<String, Object> getAdvanceExtensions() {
return this.advanceExtensions;
}
public boolean isIsLinkedToFloatingInterestRates() {
return this.isLinkedToFloatingInterestRates;
}
@@ -0,0 +1,302 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* "Advances A/C" product configuration that Fineract's loan-product schema has no home for
* (security &amp; margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring,
* cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening
* of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is
* loaded on the hot loan-origination path, so it is deliberately left untouched.
*
* Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_advance_ext")
public class LoanProductAdvanceExt {
@Id
@Column(name = "loan_product_id")
private Long loanProductId;
/** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
private LoanProduct loanProduct;
// ---- classification -------------------------------------------------
@Column(name = "product_type_group")
private String productTypeGroup;
@Column(name = "product_type_sub_group")
private String productTypeSubGroup;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "loan_product_category")
private String loanProductCategory;
@Column(name = "priority_sector")
private String prioritySector;
@Column(name = "cibil_account_type")
private String cibilAccountType;
@Column(name = "kfs_enabled")
private Boolean kfsEnabled;
@Column(name = "apr_enabled")
private Boolean aprEnabled;
// ---- facility -------------------------------------------------------
@Column(name = "facility_type")
private String facilityType;
// ---- security & margin ----------------------------------------------
@Column(name = "security_type")
private String securityType;
@Column(name = "charge_type")
private String chargeType;
@Column(name = "eligible_amount_basis")
private String eligibleAmountBasis;
@Column(name = "margin_pct")
private BigDecimal marginPct;
@Column(name = "ltv_pct")
private BigDecimal ltvPct;
@Column(name = "dp_method")
private String dpMethod;
// ---- lien -----------------------------------------------------------
@Column(name = "lien_required")
private Boolean lienRequired;
@Column(name = "lien_marking_stage")
private String lienMarkingStage;
@Column(name = "lien_certificate_required")
private Boolean lienCertificateRequired;
@Column(name = "lien_certificate_prefix")
private String lienCertificatePrefix;
@Column(name = "print_certificate_on_disbursement")
private Boolean printCertificateOnDisbursement;
// ---- interest (extras; rate bounds live on the core product) --------
@Column(name = "interest_basis")
private String interestBasis;
@Column(name = "spread_pct")
private BigDecimal spreadPct;
@Column(name = "interest_charged_on")
private String interestChargedOn;
@Column(name = "interest_posting_frequency")
private String interestPostingFrequency;
// ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------
@Column(name = "penal_applicable")
private Boolean penalApplicable;
@Column(name = "penal_rate_pct")
private BigDecimal penalRatePct;
@Column(name = "penal_applied_on")
private String penalAppliedOn;
@Column(name = "penal_grace_days")
private Integer penalGraceDays;
@Column(name = "npa_trigger")
private String npaTrigger;
@Column(name = "interest_after_npa")
private String interestAfterNpa;
@Column(name = "undebited_interest_gl_id")
private Long undebitedInterestGlId;
// ---- renewal & closure ----------------------------------------------
@Column(name = "renewal_allowed")
private Boolean renewalAllowed;
@Column(name = "renewal_period_months")
private Integer renewalPeriodMonths;
@Column(name = "renewal_grace_days")
private Integer renewalGraceDays;
@Column(name = "preclosure_allowed")
private String preclosureAllowed;
@Column(name = "lock_in_months")
private Integer lockInMonths;
@Column(name = "interest_on_preclosure")
private String interestOnPreclosure;
// ---- monitoring -----------------------------------------------------
@Column(name = "revaluation_freq_months")
private Integer revaluationFreqMonths;
@Column(name = "insurance_required")
private Boolean insuranceRequired;
@Column(name = "stock_statement_freq_months")
private Integer stockStatementFreqMonths;
// ---- cheque book ----------------------------------------------------
@Column(name = "cheque_book_enabled")
private Boolean chequeBookEnabled;
@Column(name = "cheque_type")
private String chequeType;
@Column(name = "default_cheque_book_size")
private Integer defaultChequeBookSize;
@Column(name = "pps_threshold")
private BigDecimal ppsThreshold;
@Column(name = "pps_mandatory_above")
private BigDecimal ppsMandatoryAbove;
// ---- share (co-op) --------------------------------------------------
@Column(name = "share_capital_gl_id")
private Long shareCapitalGlId;
// ---- security & lien extras -----------------------------------------
/** Pledgeable deposit product names, joined with "|". */
@Column(name = "eligible_deposit_products")
private String eligibleDepositProducts;
@Column(name = "lien_tenure_not_exceed_maturity")
private Boolean lienTenureNotExceedMaturity;
@Column(name = "lien_auto_release_on_closure")
private Boolean lienAutoReleaseOnClosure;
@Column(name = "lien_allow_multiple_deposits")
private Boolean lienAllowMultipleDeposits;
@Column(name = "lien_adjust_from_maturity")
private Boolean lienAdjustFromMaturity;
@Column(name = "lien_allow_third_party")
private Boolean lienAllowThirdParty;
@Column(name = "lien_allow_renewal_under_lien")
private Boolean lienAllowRenewalUnderLien;
// ---- limit & tenure extras ------------------------------------------
@Column(name = "repayment_type")
private String repaymentType;
@Column(name = "appropriation_order")
private String appropriationOrder;
@Column(name = "debit_balance_allow")
private Boolean debitBalanceAllow;
@Column(name = "allow_debit_after_due_date")
private Boolean allowDebitAfterDueDate;
@Column(name = "branch_level_account_type")
private Boolean branchLevelAccountType;
// ---- interest extras ------------------------------------------------
@Column(name = "interest_calc_method")
private String interestCalcMethod;
@Column(name = "interest_on_credit_balance")
private Boolean interestOnCreditBalance;
@Column(name = "interest_posting_day")
private String interestPostingDay;
// ---- NPA upgrade rules ----------------------------------------------
@Column(name = "npa_upgrade_only_all_arrears")
private Boolean npaUpgradeOnlyAllArrears;
@Column(name = "npa_keep_standard_if_deposit_covers")
private Boolean npaKeepStandardIfDepositCovers;
// ---- cheque book full config ----------------------------------------
/** Allowed book sizes (leaves), joined with "|". */
@Column(name = "cheque_book_sizes")
private String chequeBookSizes;
@Column(name = "cheque_max_books_outstanding")
private Integer chequeMaxBooksOutstanding;
@Column(name = "cheque_reissue_when_leaves")
private Integer chequeReissueWhenLeaves;
@Column(name = "cheque_first_book_free")
private Boolean chequeFirstBookFree;
@Column(name = "cheque_free_books_per_year")
private Integer chequeFreeBooksPerYear;
@Column(name = "cheque_name_printed")
private String chequeNamePrinted;
@Column(name = "cheque_print_description")
private String chequePrintDescription;
@Column(name = "cheque_stop_payment_allowed")
private Boolean chequeStopPaymentAllowed;
@Column(name = "cheque_maintain_stock_register")
private Boolean chequeMaintainStockRegister;
@Column(name = "cheque_allow_withdrawal_slip")
private Boolean chequeAllowWithdrawalSlip;
@Column(name = "cheque_block_npa")
private Boolean chequeBlockNpa;
@Column(name = "cheque_block_limit_expired")
private Boolean chequeBlockLimitExpired;
@Column(name = "cheque_block_over_dp")
private Boolean chequeBlockOverDp;
@Column(name = "cheque_block_frozen")
private Boolean chequeBlockFrozen;
@Column(name = "cheque_block_lien_released")
private Boolean chequeBlockLienReleased;
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
}
}
@@ -0,0 +1,16 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductAdvanceExtRepository extends JpaRepository<LoanProductAdvanceExt, Long> {
Optional<LoanProductAdvanceExt> findByLoanProductId(Long loanProductId);
}
@@ -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;
}
@@ -91,7 +93,9 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
summary.riskCategory(), alerts.size(),
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
// Open means undecided, not a named status: a status this count does not list would otherwise be
// dropped from the customer's open figure while the alert still sits in the feed awaiting review.
(int) alerts.stream().filter(a -> !"WHITELISTED".equals(a.status()) && !"ESCALATED".equals(a.status())).count(),
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
@@ -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()
@@ -412,13 +418,20 @@ public class CashierDepositRequestApiResource {
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
amount, ptId, txnDateStr, description, true);
} else {
// Determine real account type by checking which table owns this ID.
// Determine real account type by checking which table/column owns this ID.
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
// even for current accounts (submitted before the frontend fix).
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "RECURRING_DEPOSIT" ->
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
@@ -624,7 +637,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 +652,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 +682,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()) {
@@ -694,6 +725,64 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeFixedDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.fixedDepositAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeRecurringDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.recurringAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
@@ -785,6 +874,32 @@ public class CashierDepositRequestApiResource {
}
}
/**
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
* rather than the request's own account_type string. FD and RD accounts live in the same
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*/
private String resolveDepositAccountCategory(final Long accountId) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
if (Integer.valueOf(200).equals(depositType)) {
return "FIXED_DEPOSIT";
}
if (Integer.valueOf(300).equals(depositType)) {
return "RECURRING_DEPOSIT";
}
return "SAVINGS";
}
// Parses a Fineract transaction id string to Long.
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
private Long parseTxnIdToLong(final String txnId) {
@@ -1014,6 +1129,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;
@@ -95,12 +97,42 @@ public class ShareAdvanceCollectionApiResource {
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── acc_gl_account.classification_enum ─────────────────────────────────
// Fineract's GLAccountType: 1 = ASSET, 2 = LIABILITY, 3 = EQUITY, 4 = INCOME, 5 = EXPENSE.
private static final int GL_CLASSIFICATION_INCOME = 4;
// ── 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;
private static final int FA_SHARES_EQUITY = 4;
/**
* Restricts a {@code m_share_advance_collection c} query to receipts whose money was actually
* collected FOR a share collection, and nothing else.
*
* <p>
* The Entry Type the counter picks at save time is the only thing that says what a GL receipt was
* taken for, so it decides this too: a receipt left General, or tagged for some other purpose
* entirely, never reaches an Apply Collection or Additional Shares screen. Guarding only the
* adoption path was not enough -- a receipt adopted before that guard existed stayed on these
* lists for ever, which is exactly how a General voucher kept reappearing against share accounts.
*
* <p>
* A collection with no voucher of its own is the one thing this lets through: those come from the
* direct Share Advance Collection endpoint, which exists for nothing but share collections, so
* there is no counter tag to read and none needed.
*/
private static final String SHARE_COLLECTION_TAGGED_ONLY = """
AND (c.voucher_no IS NULL
OR EXISTS (SELECT 1 FROM m_cashier_deposit_request d
JOIN m_code_value dcv ON dcv.id = d.entry_type_cv_id
WHERE d.voucher_no = c.voucher_no AND dcv.code_value = 'Share Collection')
OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request r
JOIN m_code_value rcv ON rcv.id = r.entry_type_cv_id
WHERE r.voucher_no = c.voucher_no AND rcv.code_value = 'Share Collection'))
""";
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
@@ -150,7 +182,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,26 +218,160 @@ public class ShareAdvanceCollectionApiResource {
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
@Transactional
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
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 "
+ " 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);
}
sql.append(" ORDER BY c.collection_date, c.id");
// 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();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
final List<Map<String, Object>> rows = unappliedFor(clientId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
/**
* Unapplied receipts ready to be ticked, for one member or for everybody.
*
* Defined once and read by both this endpoint and {@link #listUnattributed}, which returns the
* same rows under {@code own} so an Apply screen needs a single call rather than two that were
* only ever querying the same table with the same filters.
*/
private List<Map<String, Object>> unappliedFor(final Long clientId) {
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, "
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS 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' "
+ SHARE_COLLECTION_TAGGED_ONLY);
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
return jdbcTemplate.queryForList(sql.toString());
}
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// GET /unattributed — share-collection receipts that name no member yet
// ══════════════════════════════════════════════════════════════════════
/**
* Approved cashier receipts tagged as a share collection that are still waiting to be attached to
* a member.
*
* <p>
* A receipt taken straight into a GL account names no member — a GL account is nobody's account —
* so it can never be auto-adopted onto whichever member's screen happens to be open. That rule
* stands. What it left behind, though, was a counter tagging a receipt "Share Collection" on
* purpose and then finding nothing on the Apply screen but "no unapplied receipt for this
* member", with no way to discover that the voucher they just raised is sitting right there.
*
* <p>
* This lists exactly those receipts so they can be seen and picked. Picking one still runs the
* same by-voucher adoption a typed voucher number does: a person at the counter deliberately
* attaching a named voucher to a member they can see. The list makes the voucher findable; it
* does not decide whose money it is.
*
* <p>
* Receipts already claimed — applied to a share account, or consumed by a Demand Draft — are left
* out, so nothing here is something a second member could take twice. A receipt that already
* names a member (a clerk collection resolved from the savings account it debited) is still
* listed here, not only on that member's own screen -- every branch counter runs the same Apply
* screen for whichever member is in front of them, so a voucher must be findable there regardless
* of which member's screen happened to raise or auto-adopt it first. Attaching it from a different
* member's screen still goes through the ordinary by-voucher lookup below, which refuses a
* mismatch rather than silently moving the money -- this list only makes the voucher visible, it
* does not reassign it.
*/
/**
* @param clientId
* the member whose own Apply screen this is. Their own unapplied receipts come back
* separately under {@code own} -- the same rows, in the same shape, that
* {@link #listUnapplied} returns -- so a screen needs ONE call rather than two, and
* those rows are left out of {@code rows} so the same receipt is never listed twice on
* one screen. Omit it and everything available comes back under {@code rows}.
*/
@GET
@Path("/unattributed")
public Response listUnattributed(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
// Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a
// voucher tagged Share Collection becomes visible everywhere the first time ANY Apply screen
// is opened, not only once the specific member it names happens to be looked up.
autoAdoptShareCollectionTagged();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT d.id::text AS id, d.voucher_no AS \"voucherNo\","
+ " d.account_no AS \"glAccountNo\", d.client_name AS \"glAccountName\", d.amount,"
+ " to_char(d.txn_date, 'YYYY-MM-DD') AS \"txnDate\","
+ " d.description, d.cashier_name AS \"cashierName\", NULL::bigint AS \"linkedClientId\""
+ " 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.client_id IS NULL 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)"
+ " UNION ALL "
+ " SELECT 'sac-' || c.id AS id, c.voucher_no AS \"voucherNo\","
+ " (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 \"glAccountNo\","
// The GL account's own name, not the member's -- "Deposited To" names an
// account on every other row of this list, and a receipt that already names a
// member still needs its own column to say whose it is (below), not to borrow
// this one and read as though a person were a GL account.
+ " (SELECT ga.name 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 \"glAccountName\","
+ " c.amount, to_char(c.collection_date, 'YYYY-MM-DD') AS \"txnDate\","
// Visibly names whose receipt this is -- listing it here does not attribute it
// (attaching it still runs the same by-voucher lookup, which refuses a
// mismatched member), but a counter deciding whether to attach it needs to see
// that up front rather than discovering it only after clicking Attach.
+ " COALESCE(c.note, 'Share collection - Voucher ' || c.voucher_no)"
+ " || ' (linked to ' || cl.display_name || ')' AS description,"
+ " c.collected_by_name AS \"cashierName\", c.client_id AS \"linkedClientId\""
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id"
// A voucher's own free-standing collection (raised straight through POST
// /shareadvancecollection, no cashier deposit or clerk request behind it) has
// no entry-type tag at all -- there is nothing else it could be, so it always
// qualifies. One that WAS adopted from a cashier deposit or clerk disbursement
// only stays listed here while that source row is still tagged Share
// Collection today -- a voucher whose entry type reads General (or was
// corrected to General after adoption) belongs on the ordinary voucher
// register, not on this Share Collection-only list, even though the earlier
// adoption already happened and cannot itself be undone here.
+ " LEFT JOIN m_cashier_deposit_request cdr ON cdr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_clerk_disbursement_request clr ON clr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_code_value cdr_cv ON cdr_cv.id = cdr.entry_type_cv_id"
+ " LEFT JOIN m_code_value clr_cv ON clr_cv.id = clr.entry_type_cv_id"
+ " WHERE c.status = 'UNAPPLIED'"
+ " AND ((cdr.id IS NULL AND clr.id IS NULL)"
+ " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')"
// This member's own receipts come back under "own" below, ready to tick; listing
// them here as well would put the same receipt on screen twice, once needing a
// tick and once an Attach, for no difference in what it is.
+ (clientId != null ? " AND c.client_id <> " + clientId : "")
+ " ORDER BY \"txnDate\" DESC");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
out.put("own", clientId == null ? List.of() : unappliedFor(clientId));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
@@ -229,9 +395,18 @@ public class ShareAdvanceCollectionApiResource {
this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
// gl_code carries the same subquery listUnapplied() uses -- the GL Account picker on screen
// follows whichever receipt gets ticked (see toggleVoucher()/toggle() on the client), and a
// receipt resolved through this search must feed that picker exactly the same as one resolved
// from the member's own list, or the picker is silently left on "Select GL account…" for
// every voucher found this way.
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 "
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
+ " c.applied_share_account_id,"
+ " (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.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
@@ -271,19 +446,33 @@ 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");
// Every postable GL account, same as before this endpoint was ever narrowed to "only
// accounts that currently hold an eligible receipt" -- that narrowing meant to help the
// counter, but it back-fired the moment a genuine voucher sat in a GL account no live
// receipt query happened to match (a fee posted somewhere unusual, a receipt taken
// yesterday), silently dropping it from the list with no way to pick it by hand. The full
// list is always safe to search against; nothing here is auto-picked for the counter (see
// ngOnInit in the dialog) unless it is genuinely the only option.
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");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
@@ -316,6 +505,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
// ══════════════════════════════════════════════════════════════════════
@@ -377,20 +607,10 @@ public class ShareAdvanceCollectionApiResource {
final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move
// one member's money into another's share capital. Checked per receipt, since a settlement
// can now carry several.
final Long acctClientId = toLong(acct.get("client_id"));
for (final Map<String, Object> row : collections) {
if (!toLong(row.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
}
}
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
}
// No client-match check here by deliberate choice: any receipt tagged Share Collection can
// be applied to any share account, regardless of which member it was originally recorded
// against. The counter is trusted to attach the right voucher by hand -- nothing here blocks
// it if they choose otherwise.
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
@@ -472,31 +692,62 @@ 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.
//
// A receipt that already sits in an INCOME-classified GL is left exactly where it is, not
// debited out here at all -- an entry fee the cashier deposited straight into its own income
// account (the ordinary case: "Share Entry", "Income From Fees", whatever the chart calls it)
// was already recognised as income the moment it was banked. Debiting it out and crediting a
// fresh income leg below for the same rupees would recognise it a second time -- the
// duplicate a counter tagging every entry-fee GL as Income now stops before it is ever posted.
// A ticket genuinely sitting in a non-income account (Share Sundry, most often -- the legacy
// single-receipt case where share value and fee arrived together) still needs clearing, so it
// is debited as before and its amount still moves to Income below.
BigDecimal feeAlreadyRecognised = BigDecimal.ZERO;
for (final Map<String, Object> row : collections) {
final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
final BigDecimal rowAmount = (BigDecimal) row.get("amount");
if (chargeColl == null && isIncomeGl(receiptGl)) {
feeAlreadyRecognised = feeAlreadyRecognised.add(rowAmount);
continue;
}
insertEntry(officeId, receiptGl, DEBIT, rowAmount, 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,
"Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
// income account itself), so moving it again here would double it. When it was bundled into
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
// to move, exactly as before.
// Cr Income from Fees for the entry fee, but only for whatever part of it is not already
// sitting recognised as income (feeAlreadyRecognised, resolved above) -- that part already
// sits in whichever GL the cashier deposited it to, so moving it again here would double it.
// When it was bundled into the same Sundry receipt(s) as the share value (chargeColl == null)
// and genuinely never touched an income account, it still needs to move, exactly as before.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
if (chargeColl == null) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
final BigDecimal feeStillToRecognise = chargeAmount.subtract(feeAlreadyRecognised);
if (feeStillToRecognise.compareTo(BigDecimal.ZERO) > 0) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share entry fee for " + accountNo);
}
}
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", shareAccountId);
@@ -534,17 +785,24 @@ public class ShareAdvanceCollectionApiResource {
// Every receipt in this settlement is marked applied, not just the one in the path — they
// were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
// receipt is marked applied too, even though it posted no GL entry of its own.
//
// client_id is rewritten to this account's own client here too -- a voucher can now be found
// and applied from any member's screen, so whoever last searched it (the value it carried
// going in) is not necessarily who its money actually became share capital for. Leaving the
// old value in place would make the receipt's own record misname whose it is the moment it
// settles, which is exactly the figure a receipt-detail popup reads back afterwards.
final Object settledClientId = acct.get("client_id");
for (final Long id : collectionIds) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
+ " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, id);
}
if (chargeCollectionId != null) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
+ " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, chargeCollectionId);
}
// Reveals the purchase transaction in the passbook and transactions views.
@@ -595,6 +853,102 @@ 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 -- it can only ever be claimed through the manual by-voucher search
* (including a deliberate click in the Unattributed list), where a person at the counter is
* actively vouching for it against a member they can see.
*
* 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 converted here
* unconditionally, regardless of who is asking: the point of this pass is that the voucher
* becomes visible and findable everywhere the moment it is tagged and approved, not only on the
* one screen that happens to belong to the member it names. Converting it does not hand it to
* anybody -- {@link #findByVoucher} still refuses to apply a receipt to an account whose client_id
* does not match, so the member it actually names is protected the same way whether this ran for
* their own screen or a different one.
*/
private void autoAdoptShareCollectionTagged() {
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"));
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);
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 +958,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, d.description,"
+ " 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 +974,26 @@ 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). Exact match required -- "General" and an
// untagged row used to be let through too, on the reasoning that untyped money carries no
// evidence of a conflicting purpose. That reasoning cuts both ways: untyped money is exactly
// as often share-collection cash the counter forgot to tag, and letting it through here
// quietly rewarded skipping the tag. A voucher only ever becomes reachable for a share
// collection by being tagged for one, on purpose, at save time.
final String depositEntryType = (String) dep.get("entry_type");
if (!"Share Collection".equals(depositEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as "
+ (depositEntryType == null || depositEntryType.isBlank() ? "an untagged entry" : depositEntryType)
+ ", not Share Collection. Ask the cashier to correct the entry type at Cashier › Transaction Entry, "
+ "then search again.");
}
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;
@@ -632,18 +1008,32 @@ public class ShareAdvanceCollectionApiResource {
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// What the cashier actually typed at save time, when they typed anything -- same preference
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
// auto-adoption or by this manual by-voucher lookup. Falls back to a plain label only for a
// deposit nobody described.
final String depDescription = (String) dep.get("description");
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
: "Adopted from cashier deposit voucher " + voucherNo;
final String receiptNo = nextReceiptNo();
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), dep.get("amount"), currency,
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
"Adopted from cashier deposit voucher " + voucherNo);
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_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"
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id,"
// Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly
// adopted voucher must feed the on-screen GL Account picker exactly like one
// that was already sitting on the member's list, or the picker is silently
// left on "Select GL account…" for every voucher adopted this way.
+ " (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.receipt_no = ?",
receiptNo);
}
@@ -665,16 +1055,30 @@ 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 clerk picked by hand, so it carries the identical ambiguity, and the identical
// exact-match requirement: a voucher only becomes reachable for a share collection by being
// tagged Share Collection at save time, "General" and untagged included.
final String reqEntryType = (String) req.get("entry_type");
if (!"Share Collection".equals(reqEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as "
+ (reqEntryType == null || reqEntryType.isBlank() ? "an untagged entry" : reqEntryType)
+ ", not Share Collection. Ask the clerk to correct the entry type on this request, then search again.");
}
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
@@ -692,16 +1096,27 @@ 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"
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id,"
// Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly
// adopted voucher must feed the on-screen GL Account picker exactly like one
// that was already sitting on the member's list, or the picker is silently
// left on "Select GL account…" for every voucher adopted this way.
+ " (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.receipt_no = ?",
receiptNo);
}
@@ -809,6 +1224,55 @@ 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);
}
/**
* Whether a GL account is already an INCOME account (Fineract's own {@code classification_enum}
* for the "Income" account type on {@code acc_gl_account}).
*
* <p>
* Used to tell an entry-fee receipt that already sits recognised as income (deposited straight
* into its own income GL by the cashier) apart from one still sitting in a holding account like
* Share Sundry — only the latter still needs moving into income here.
*/
private boolean isIncomeGl(final Long glAccountId) {
if (glAccountId == null) {
return false;
}
final Integer classification = jdbcTemplate.queryForObject(
"SELECT classification_enum FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
return classification != null && classification == GL_CLASSIFICATION_INCOME;
}
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,11 +742,18 @@ public class ClerkDisbursementRequestApiResource {
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
approvedGlTxnId = collGlTxnId;
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description);
} else if (!withdrawalSavingsTxnRefs.isEmpty()) {
// No bridging entry was needed: the withdrawal's own auto-posted credit
// leg already lands on the target GL directly (the savings product's own
// payment-type mapping points there). That withdrawal's own transaction is
// therefore the real posting -- there is no second one to remember instead.
approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0);
}
}
}
@@ -756,11 +800,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 +1151,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 +1196,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 +1226,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 +1259,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 +1282,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 +1341,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 +1432,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();
@@ -1332,7 +1446,15 @@ public class ClerkDisbursementRequestApiResource {
return null;
}
private void postGlOffsetEntry(
/**
* @return the real {@code acc_gl_journal_entry.transaction_id} this posts, so a caller that needs
* to remember which posting was theirs — the collection fallback below, most importantly
* — is not left with nothing to record. Returning it here rather than discarding it, as
* this method used to, was the entire reason {@code gl_transaction_id} stayed blank on
* every clerk collection that fell back to this path: nothing downstream (Share Advance
* Collection's GL-detail popup among them) had anything to look the posting up by.
*/
private String postGlOffsetEntry(
final Long drGlAccountId, final Long crGlAccountId,
final BigDecimal amount, final Long officeId,
final String currencyCode, final String txnDateStr, final String comments) {
@@ -1368,7 +1490,9 @@ public class ClerkDisbursementRequestApiResource {
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
// The disbursement register raises its own voucher; suppress the journal command's own.
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
return result != null ? result.getTransactionId() : null;
}
// ── Combined GL entry helpers ─────────────────────────────────────────────
@@ -1402,9 +1526,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 +1544,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 +1559,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 +1572,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;
@@ -89,6 +91,24 @@ import org.springframework.transaction.annotation.Transactional;
@Slf4j
public class DividendClaimRequestApiResource {
/**
* The next blank to draw a dividend draft on: the lowest unused number in the register.
*
* Ordered by the printed number, never by row id. Row id is insertion order, which matches the
* series only for the first book registered — a later book allocates from the middle and hands
* out numbers out of sequence, which is what makes an unused number in a run impossible to
* account for. The numeric cast is guarded because a series need not be plain digits; a prefixed
* number sorts as text after the numeric ones, matching how the leaflet register displays it.
*
* SKIP LOCKED so two concurrent claims take different leaflets rather than one waiting on the
* other's transaction.
*/
private static final String LOWEST_AVAILABLE_LEAFLET_SQL = """
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
ORDER BY CASE WHEN dd_number ~ '^[0-9]+$' THEN CAST(dd_number AS BIGINT) END ASC NULLS LAST, dd_number ASC
LIMIT 1 FOR UPDATE SKIP LOCKED
""";
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
@@ -189,11 +209,12 @@ public class DividendClaimRequestApiResource {
.orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has "
+ "just taken it. Go back and choose another leaflet."))
// No leaflet named (a caller that is not the leaflets screen): fall back to the
// oldest available, which is the order a series is meant to be consumed in.
: jdbcTemplate.queryForList("""
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
""").stream().findFirst().orElseThrow(() -> apiError(409,
// lowest unused number, which is the order a series is meant to be consumed in.
// Ordered by the printed number rather than by row id — id is insertion order, and
// the two diverge as soon as a second book is registered, which hands out numbers
// from the middle of a series instead of its start.
: jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL)
.stream().findFirst().orElseThrow(() -> apiError(409,
"No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."));
final Long leafletId = ((Number) leaflet.get("id")).longValue();
final String ddNumber = (String) leaflet.get("dd_number");
@@ -428,11 +449,9 @@ public class DividendClaimRequestApiResource {
final Map<String, Object> record, final BigDecimal amount, final String currencyCode, final LocalDate today,
final AppUser currentUser) {
// Oldest series first, and locked so two concurrent approvals cannot take the same leaflet.
final Map<String, Object> leaflet = jdbcTemplate.queryForList("""
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
""").stream().findFirst()
// Lowest unused number first, and locked so two concurrent approvals cannot take the same
// leaflet.
final Map<String, Object> leaflet = jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL).stream().findFirst()
.orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory."));
final Long leafletId = ((Number) leaflet.get("id")).longValue();
final String ddNumber = (String) leaflet.get("dd_number");
@@ -520,7 +539,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 +590,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,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
@@ -98,6 +99,15 @@ public class LoanAccountAutoStarter {
loanBalanceService);
}
@Bean
@Conditional(FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.class)
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor feesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
return new FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator,
loanBalanceService);
}
@Bean
@Conditional(RBIIndiaLoanRepaymentScheduleTransactionProcessorCondition.class)
public RBILoanRepaymentScheduleTransactionProcessor rbiLoanRepaymentScheduleTransactionProcessor(
@@ -107,6 +107,7 @@ import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundEx
import org.apache.fineract.portfolio.loanproduct.productmix.data.ProductMixData;
import org.apache.fineract.portfolio.loanproduct.productmix.service.ProductMixReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
@@ -142,7 +143,8 @@ public class LoanProductsApiResource {
LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME,
LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME));
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME,
"advanceExtensions"));
private static final Set<String> PRODUCT_MIX_DATA_PARAMETERS = new HashSet<>(
Arrays.asList("productId", "productName", "restrictedProducts", "allowedProducts", "productOptions"));
@@ -153,6 +155,7 @@ public class LoanProductsApiResource {
private final PlatformSecurityContext context;
private final LoanProductReadPlatformService loanProductReadPlatformService;
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
private final ChargeReadPlatformService chargeReadPlatformService;
private final CurrencyReadPlatformService currencyReadPlatformService;
private final FundReadPlatformService fundReadPlatformService;
@@ -384,6 +387,9 @@ public class LoanProductsApiResource {
if (settings.isTemplate()) {
loanProduct = handleTemplate(loanProduct);
}
// "Advances A/C" satellite fields, so the edit form can repopulate. Set last: the accounting and template
// steps above return fresh LoanProductData copies that would otherwise drop it. Empty map for a standard product.
loanProduct.setAdvanceExtensions(this.loanProductAdvanceExtService.retrieveAsMap(productId));
return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS);
}
@@ -72,6 +72,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.InterestRecalculationCom
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductValueConditionType;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMethod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
@@ -212,6 +213,13 @@ public final class LoanProductDataValidator {
LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME //
));
static {
// "Advances A/C" satellite params are persisted by LoanProductAdvanceExtService, which owns the
// authoritative list of their names — added here rather than restated so the two cannot drift, and so
// the unsupported-parameter check keeps rejecting genuine typos.
SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductAdvanceExtService.ADVANCE_PARAMETERS));
}
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName,
@@ -0,0 +1,373 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.service;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
import org.springframework.stereotype.Service;
/**
* Persists and reads the "Advances A/C" satellite ({@link LoanProductAdvanceExt}).
*
* Only touched for an Advances product: a standard loan product carries none of {@link #ADVANCE_PARAMETERS},
* so {@link #saveOrUpdate} early-returns and never writes an empty row. Called from inside the loan-product
* create/update transaction, so a failure here rolls the whole product back.
*/
@Service
@RequiredArgsConstructor
public class LoanProductAdvanceExtService {
private final LoanProductAdvanceExtRepository repository;
/**
* Every Advance parameter name. Shared with {@code LoanProductDataValidator} (added to its
* SUPPORTED_PARAMETERS) so the two can never disagree about what is accepted. These are the
* satellite-only fields; core fields (principal, rate, tenure, npaDays, delinquencyBucketId,
* graceOnArrearsAgeing, inArrearsTolerance, accountingRule...) are handled by the standard product.
*/
public static final String[] ADVANCE_PARAMETERS = { "productTypeGroup", "productTypeSubGroup", "glAccount",
"loanProductCategory", "prioritySector", "cibilAccountType", "kfsEnabled", "aprEnabled", "facilityType",
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
"shareCapitalGl",
// security & lien extras
"eligibleDepositProducts", "lienTenureNotExceedMaturity", "lienAutoReleaseOnClosure",
"lienAllowMultipleDeposits", "lienAdjustFromMaturity", "lienAllowThirdParty", "lienAllowRenewalUnderLien",
// limit & tenure extras
"repaymentType", "appropriationOrder", "debitBalanceAllow", "allowDebitAfterDueDate", "branchLevelAccountType",
// interest extras
"interestCalcMethod", "interestOnCreditBalance", "interestPostingDay",
// NPA upgrade rules
"npaUpgradeOnlyAllArrears", "npaKeepStandardIfDepositCovers",
// cheque book full config
"chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree",
"chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed",
"chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired",
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased" };
/**
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
* caller can fold them into the loan-product audit {@code changes} map.
*/
public Map<String, Object> saveOrUpdate(final LoanProduct loanProduct, final JsonCommand command) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (!hasAnyAdvanceParameter(command)) {
return changes;
}
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
applyScalarFields(ext, command, changes);
this.repository.save(ext);
return changes;
}
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
.orElseGet(LinkedHashMap::new);
}
private boolean hasAnyAdvanceParameter(final JsonCommand command) {
for (final String param : ADVANCE_PARAMETERS) {
if (command.parameterExists(param)) {
return true;
}
}
return false;
}
private void applyScalarFields(final LoanProductAdvanceExt ext, final JsonCommand command,
final Map<String, Object> changes) {
setString(command, "productTypeGroup", ext::getProductTypeGroup, ext::setProductTypeGroup, changes);
setString(command, "productTypeSubGroup", ext::getProductTypeSubGroup, ext::setProductTypeSubGroup, changes);
setLong(command, "glAccount", ext::getGlAccountId, ext::setGlAccountId, changes);
setString(command, "loanProductCategory", ext::getLoanProductCategory, ext::setLoanProductCategory, changes);
setString(command, "prioritySector", ext::getPrioritySector, ext::setPrioritySector, changes);
setString(command, "cibilAccountType", ext::getCibilAccountType, ext::setCibilAccountType, changes);
setBoolean(command, "kfsEnabled", ext::getKfsEnabled, ext::setKfsEnabled, changes);
setBoolean(command, "aprEnabled", ext::getAprEnabled, ext::setAprEnabled, changes);
setString(command, "facilityType", ext::getFacilityType, ext::setFacilityType, changes);
setString(command, "securityType", ext::getSecurityType, ext::setSecurityType, changes);
setString(command, "chargeType", ext::getChargeType, ext::setChargeType, changes);
setString(command, "eligibleAmountBasis", ext::getEligibleAmountBasis, ext::setEligibleAmountBasis, changes);
setDecimal(command, "margin", ext::getMarginPct, ext::setMarginPct, changes);
setDecimal(command, "ltv", ext::getLtvPct, ext::setLtvPct, changes);
setString(command, "dpMethod", ext::getDpMethod, ext::setDpMethod, changes);
setBoolean(command, "lienRequired", ext::getLienRequired, ext::setLienRequired, changes);
setString(command, "lienMarkingStage", ext::getLienMarkingStage, ext::setLienMarkingStage, changes);
setBoolean(command, "lienCertificateRequired", ext::getLienCertificateRequired, ext::setLienCertificateRequired,
changes);
setString(command, "lienCertificatePrefix", ext::getLienCertificatePrefix, ext::setLienCertificatePrefix,
changes);
setBoolean(command, "printCertificateOnDisbursement", ext::getPrintCertificateOnDisbursement,
ext::setPrintCertificateOnDisbursement, changes);
setString(command, "interestBasis", ext::getInterestBasis, ext::setInterestBasis, changes);
setDecimal(command, "spread", ext::getSpreadPct, ext::setSpreadPct, changes);
setString(command, "interestChargedOn", ext::getInterestChargedOn, ext::setInterestChargedOn, changes);
setString(command, "interestPostingFrequency", ext::getInterestPostingFrequency,
ext::setInterestPostingFrequency, changes);
setBoolean(command, "penalApplicable", ext::getPenalApplicable, ext::setPenalApplicable, changes);
setDecimal(command, "penalRate", ext::getPenalRatePct, ext::setPenalRatePct, changes);
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
setInteger(command, "renewalPeriodMonths", ext::getRenewalPeriodMonths, ext::setRenewalPeriodMonths, changes);
setInteger(command, "renewalGraceDays", ext::getRenewalGraceDays, ext::setRenewalGraceDays, changes);
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
changes);
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
setInteger(command, "stockStatementFreqMonths", ext::getStockStatementFreqMonths,
ext::setStockStatementFreqMonths, changes);
setBoolean(command, "chequeBookEnabled", ext::getChequeBookEnabled, ext::setChequeBookEnabled, changes);
setString(command, "chequeType", ext::getChequeType, ext::setChequeType, changes);
setInteger(command, "defaultChequeBookSize", ext::getDefaultChequeBookSize, ext::setDefaultChequeBookSize,
changes);
setDecimal(command, "ppsThreshold", ext::getPpsThreshold, ext::setPpsThreshold, changes);
setDecimal(command, "ppsMandatoryAbove", ext::getPpsMandatoryAbove, ext::setPpsMandatoryAbove, changes);
setLong(command, "shareCapitalGl", ext::getShareCapitalGlId, ext::setShareCapitalGlId, changes);
// security & lien extras
setString(command, "eligibleDepositProducts", ext::getEligibleDepositProducts, ext::setEligibleDepositProducts,
changes);
setBoolean(command, "lienTenureNotExceedMaturity", ext::getLienTenureNotExceedMaturity,
ext::setLienTenureNotExceedMaturity, changes);
setBoolean(command, "lienAutoReleaseOnClosure", ext::getLienAutoReleaseOnClosure,
ext::setLienAutoReleaseOnClosure, changes);
setBoolean(command, "lienAllowMultipleDeposits", ext::getLienAllowMultipleDeposits,
ext::setLienAllowMultipleDeposits, changes);
setBoolean(command, "lienAdjustFromMaturity", ext::getLienAdjustFromMaturity, ext::setLienAdjustFromMaturity,
changes);
setBoolean(command, "lienAllowThirdParty", ext::getLienAllowThirdParty, ext::setLienAllowThirdParty, changes);
setBoolean(command, "lienAllowRenewalUnderLien", ext::getLienAllowRenewalUnderLien,
ext::setLienAllowRenewalUnderLien, changes);
// limit & tenure extras
setString(command, "repaymentType", ext::getRepaymentType, ext::setRepaymentType, changes);
setString(command, "appropriationOrder", ext::getAppropriationOrder, ext::setAppropriationOrder, changes);
setBoolean(command, "debitBalanceAllow", ext::getDebitBalanceAllow, ext::setDebitBalanceAllow, changes);
setBoolean(command, "allowDebitAfterDueDate", ext::getAllowDebitAfterDueDate, ext::setAllowDebitAfterDueDate,
changes);
setBoolean(command, "branchLevelAccountType", ext::getBranchLevelAccountType, ext::setBranchLevelAccountType,
changes);
// interest extras
setString(command, "interestCalcMethod", ext::getInterestCalcMethod, ext::setInterestCalcMethod, changes);
setBoolean(command, "interestOnCreditBalance", ext::getInterestOnCreditBalance, ext::setInterestOnCreditBalance,
changes);
setString(command, "interestPostingDay", ext::getInterestPostingDay, ext::setInterestPostingDay, changes);
// NPA upgrade rules
setBoolean(command, "npaUpgradeOnlyAllArrears", ext::getNpaUpgradeOnlyAllArrears,
ext::setNpaUpgradeOnlyAllArrears, changes);
setBoolean(command, "npaKeepStandardIfDepositCovers", ext::getNpaKeepStandardIfDepositCovers,
ext::setNpaKeepStandardIfDepositCovers, changes);
// cheque book full config
setString(command, "chequeBookSizes", ext::getChequeBookSizes, ext::setChequeBookSizes, changes);
setInteger(command, "chequeMaxBooksOutstanding", ext::getChequeMaxBooksOutstanding,
ext::setChequeMaxBooksOutstanding, changes);
setInteger(command, "chequeReissueWhenLeaves", ext::getChequeReissueWhenLeaves, ext::setChequeReissueWhenLeaves,
changes);
setBoolean(command, "chequeFirstBookFree", ext::getChequeFirstBookFree, ext::setChequeFirstBookFree, changes);
setInteger(command, "chequeFreeBooksPerYear", ext::getChequeFreeBooksPerYear, ext::setChequeFreeBooksPerYear,
changes);
setString(command, "chequeNamePrinted", ext::getChequeNamePrinted, ext::setChequeNamePrinted, changes);
setString(command, "chequePrintDescription", ext::getChequePrintDescription, ext::setChequePrintDescription,
changes);
setBoolean(command, "chequeStopPaymentAllowed", ext::getChequeStopPaymentAllowed,
ext::setChequeStopPaymentAllowed, changes);
setBoolean(command, "chequeMaintainStockRegister", ext::getChequeMaintainStockRegister,
ext::setChequeMaintainStockRegister, changes);
setBoolean(command, "chequeAllowWithdrawalSlip", ext::getChequeAllowWithdrawalSlip,
ext::setChequeAllowWithdrawalSlip, changes);
setBoolean(command, "chequeBlockNpa", ext::getChequeBlockNpa, ext::setChequeBlockNpa, changes);
setBoolean(command, "chequeBlockLimitExpired", ext::getChequeBlockLimitExpired, ext::setChequeBlockLimitExpired,
changes);
setBoolean(command, "chequeBlockOverDp", ext::getChequeBlockOverDp, ext::setChequeBlockOverDp, changes);
setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes);
setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased,
changes);
}
// ---- typed setters: only write params that are present, and only report real changes ----------
private interface Getter<T> {
T get();
}
private interface Ser<T> {
void set(T value);
}
private <T> void putIfChanged(final String param, final T oldValue, final T newValue, final Ser<T> setter,
final Map<String, Object> changes) {
if (differs(oldValue, newValue)) {
setter.set(newValue);
changes.put(param, newValue);
}
}
private void setString(final JsonCommand command, final String param, final Getter<String> getter,
final Ser<String> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.stringValueOfParameterNamedAllowingNull(param), setter, changes);
}
}
private void setLong(final JsonCommand command, final String param, final Getter<Long> getter,
final Ser<Long> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.longValueOfParameterNamed(param), setter, changes);
}
}
private void setInteger(final JsonCommand command, final String param, final Getter<Integer> getter,
final Ser<Integer> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.integerValueOfParameterNamed(param), setter, changes);
}
}
private void setDecimal(final JsonCommand command, final String param, final Getter<BigDecimal> getter,
final Ser<BigDecimal> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.bigDecimalValueOfParameterNamed(param), setter, changes);
}
}
/**
* The form stores its yes/no dropdowns as the strings "YES"/"NO" and its checkboxes as real booleans;
* accept both shapes so a "NO" is not silently read as null.
*/
private void setBoolean(final JsonCommand command, final String param, final Getter<Boolean> getter,
final Ser<Boolean> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), asBoolean(command.jsonElement(param)), setter, changes);
}
}
private static Boolean asBoolean(final JsonElement element) {
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
return null;
}
if (element.getAsJsonPrimitive().isBoolean()) {
return element.getAsBoolean();
}
final String raw = element.getAsString();
if (raw == null || raw.isBlank()) {
return null;
}
return "true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "1".equals(raw);
}
/** BigDecimal.equals compares scale, so 2.0 vs 2.00 would read as a change; compareTo does not. */
private static boolean differs(final Object oldValue, final Object newValue) {
if (oldValue instanceof BigDecimal a && newValue instanceof BigDecimal b) {
return a.compareTo(b) != 0;
}
return !Objects.equals(oldValue, newValue);
}
private static Map<String, Object> toMap(final LoanProductAdvanceExt ext) {
final Map<String, Object> map = new LinkedHashMap<>();
map.put("productTypeGroup", ext.getProductTypeGroup());
map.put("productTypeSubGroup", ext.getProductTypeSubGroup());
map.put("glAccount", ext.getGlAccountId());
map.put("loanProductCategory", ext.getLoanProductCategory());
map.put("prioritySector", ext.getPrioritySector());
map.put("cibilAccountType", ext.getCibilAccountType());
map.put("kfsEnabled", ext.getKfsEnabled());
map.put("aprEnabled", ext.getAprEnabled());
map.put("facilityType", ext.getFacilityType());
map.put("securityType", ext.getSecurityType());
map.put("chargeType", ext.getChargeType());
map.put("eligibleAmountBasis", ext.getEligibleAmountBasis());
map.put("margin", ext.getMarginPct());
map.put("ltv", ext.getLtvPct());
map.put("dpMethod", ext.getDpMethod());
map.put("lienRequired", ext.getLienRequired());
map.put("lienMarkingStage", ext.getLienMarkingStage());
map.put("lienCertificateRequired", ext.getLienCertificateRequired());
map.put("lienCertificatePrefix", ext.getLienCertificatePrefix());
map.put("printCertificateOnDisbursement", ext.getPrintCertificateOnDisbursement());
map.put("interestBasis", ext.getInterestBasis());
map.put("spread", ext.getSpreadPct());
map.put("interestChargedOn", ext.getInterestChargedOn());
map.put("interestPostingFrequency", ext.getInterestPostingFrequency());
map.put("penalApplicable", ext.getPenalApplicable());
map.put("penalRate", ext.getPenalRatePct());
map.put("penalAppliedOn", ext.getPenalAppliedOn());
map.put("penalGraceDays", ext.getPenalGraceDays());
map.put("npaTrigger", ext.getNpaTrigger());
map.put("interestAfterNpa", ext.getInterestAfterNpa());
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
map.put("renewalAllowed", ext.getRenewalAllowed());
map.put("renewalPeriodMonths", ext.getRenewalPeriodMonths());
map.put("renewalGraceDays", ext.getRenewalGraceDays());
map.put("preclosureAllowed", ext.getPreclosureAllowed());
map.put("lockInMonths", ext.getLockInMonths());
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
map.put("insuranceRequired", ext.getInsuranceRequired());
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
map.put("chequeBookEnabled", ext.getChequeBookEnabled());
map.put("chequeType", ext.getChequeType());
map.put("defaultChequeBookSize", ext.getDefaultChequeBookSize());
map.put("ppsThreshold", ext.getPpsThreshold());
map.put("ppsMandatoryAbove", ext.getPpsMandatoryAbove());
map.put("shareCapitalGl", ext.getShareCapitalGlId());
map.put("eligibleDepositProducts", ext.getEligibleDepositProducts());
map.put("lienTenureNotExceedMaturity", ext.getLienTenureNotExceedMaturity());
map.put("lienAutoReleaseOnClosure", ext.getLienAutoReleaseOnClosure());
map.put("lienAllowMultipleDeposits", ext.getLienAllowMultipleDeposits());
map.put("lienAdjustFromMaturity", ext.getLienAdjustFromMaturity());
map.put("lienAllowThirdParty", ext.getLienAllowThirdParty());
map.put("lienAllowRenewalUnderLien", ext.getLienAllowRenewalUnderLien());
map.put("repaymentType", ext.getRepaymentType());
map.put("appropriationOrder", ext.getAppropriationOrder());
map.put("debitBalanceAllow", ext.getDebitBalanceAllow());
map.put("allowDebitAfterDueDate", ext.getAllowDebitAfterDueDate());
map.put("branchLevelAccountType", ext.getBranchLevelAccountType());
map.put("interestCalcMethod", ext.getInterestCalcMethod());
map.put("interestOnCreditBalance", ext.getInterestOnCreditBalance());
map.put("interestPostingDay", ext.getInterestPostingDay());
map.put("npaUpgradeOnlyAllArrears", ext.getNpaUpgradeOnlyAllArrears());
map.put("npaKeepStandardIfDepositCovers", ext.getNpaKeepStandardIfDepositCovers());
map.put("chequeBookSizes", ext.getChequeBookSizes());
map.put("chequeMaxBooksOutstanding", ext.getChequeMaxBooksOutstanding());
map.put("chequeReissueWhenLeaves", ext.getChequeReissueWhenLeaves());
map.put("chequeFirstBookFree", ext.getChequeFirstBookFree());
map.put("chequeFreeBooksPerYear", ext.getChequeFreeBooksPerYear());
map.put("chequeNamePrinted", ext.getChequeNamePrinted());
map.put("chequePrintDescription", ext.getChequePrintDescription());
map.put("chequeStopPaymentAllowed", ext.getChequeStopPaymentAllowed());
map.put("chequeMaintainStockRegister", ext.getChequeMaintainStockRegister());
map.put("chequeAllowWithdrawalSlip", ext.getChequeAllowWithdrawalSlip());
map.put("chequeBlockNpa", ext.getChequeBlockNpa());
map.put("chequeBlockLimitExpired", ext.getChequeBlockLimitExpired());
map.put("chequeBlockOverDp", ext.getChequeBlockOverDp());
map.put("chequeBlockFrozen", ext.getChequeBlockFrozen());
map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased());
return map;
}
}
@@ -97,6 +97,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
private final CreditAllocationsJsonParser creditAllocationsJsonParser;
private final LoanProductAssembler loanProductAssembler;
private final LoanProductUpdateUtil loanProductUpdateUtil;
// "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book) that m_product_loan has no home for.
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger();
private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger();
@@ -141,6 +143,10 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(loanProduct);
// "Advances A/C" satellite fields, keyed on the product's generated id. A standard product carries
// none of these parameters and writes no row. Same transaction: a failure rolls the product back.
this.loanProductAdvanceExtService.saveOrUpdate(loanProduct, command);
// save accounting mappings
this.accountMappingWritePlatformService.createLoanProductToGLAccountMapping(loanProduct.getId(), command);
// check if the office specific products are enabled. If yes, then
@@ -303,6 +309,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(product);
}
// "Advances A/C" satellite fields. Outside the changes check above (those track only the standard
// product columns) so an edit that touched only an advance field is still saved, and its per-field
// diff is folded into the same audit changes map.
changes.putAll(this.loanProductAdvanceExtService.saveOrUpdate(product, command));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loanProductId) //
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.rate.domain.RateRepositoryWrapper;
import org.apache.fineract.portfolio.rate.service.RateReadService;
@@ -82,11 +83,12 @@ public class LoanProductConfiguration {
DelinquencyBucketRepository delinquencyBucketRepository,
LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory,
AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser,
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) {
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil,
LoanProductAdvanceExtService loanProductAdvanceExtService) {
return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator,
fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil,
floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository,
loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser,
loanProductAssembler, loanProductUpdateUtil);
loanProductAssembler, loanProductUpdateUtil, loanProductAdvanceExtService);
}
}
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
@QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
HashMap request = new HashMap<>();
request.put("annualInterestRate", annualInterestRate);
request.put("tenureInMonths", tenureInMonths);
if (tenureInDays != null) {
request.put("tenureInDays", tenureInDays);
}
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
request.put("principalAmount", principalAmount);
@@ -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
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
.notLessThanMin(BigDecimal.valueOf(0));
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
// an addition, so only one of the two needs to be a positive value.
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
if (tenureInDays != null && tenureInDays > 0) {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
} else {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
}
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
@@ -414,20 +414,33 @@ public class SavingsAccountAssembler {
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
// account number typed for a linked savings/loan account elsewhere in this form is verified
// before being accepted.
//
// For a term deposit (FD/RD), the product screen labels this same flag "Deposit Account Link" --
// it links an existing Savings/Current account (linkedAccountId, set below), not a co-op share
// account, so it's validated against that field instead of shareAccountId.
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
final boolean isTermDeposit = account.depositAccountType().isFixedDeposit() || account.depositAccountType().isRecurringDeposit();
if (isTermDeposit) {
final Long linkedAccountIdForCheck = this.fromApiJsonHelper.extractLongNamed(linkedAccountIdParamName, element);
if (linkedAccountIdForCheck == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.linked.account.required",
"A Savings/Current account must be linked when Deposit Account Link is enabled.");
}
} else {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
@@ -1512,6 +1512,23 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (depositAccountType.isFixedDeposit()) {
((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
FixedDepositAccount fdAccount = ((FixedDepositAccount) account);
// Post the interest transaction(s) updateMaturityStatus() just created BEFORE any maturity
// closure below runs. The closure's own withdrawal (handleWithdrawal -> postJournalEntries)
// snapshots "existing" transactions the moment IT starts, which by then already includes
// this interest posting -- so its own posting treats the interest as already accounted for
// and skips it, while this method's own final postJournalEntries() call (previously relying
// on the interest staying out of existingTransactionIds so it would post there instead)
// never actually saw it get posted either: for an FD whose entire term is a single posting
// period -- interest posts and the account matures in this same run -- that left the
// interest transaction with a real ledger balance but no matching GL entry at all, forever.
// Posting it now, while it is still the only new thing on the account, gives it exactly one
// correct journal entry, and folding it into existingTransactionIds below (the same way
// markWithdrawalsAsAlreadyPosted() already does for the withdrawal) keeps the closure branch
// and the final call from ever trying to post it again.
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
// handle maturity instructions
if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) {
@@ -1524,11 +1541,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// Every maturity-instruction branch takes the balance out of this account through
// SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal
// entries itself. existingTransactionIds was snapshotted at the top of this method --
// before that withdrawal existed -- so the postJournalEntries() call at the end would
// treat it as new and post it a second time. Interest postings created by
// updateMaturityStatus() above are still genuinely unposted at this point and must stay
// out of the snapshot so that final call can post them exactly once.
// entries itself. existingTransactionIds was refreshed just above (after the interest was
// posted) -- before that withdrawal existed -- so the postJournalEntries() call at the end
// would treat it as new and post it a second time without this.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
@@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.savings.service;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement;
@@ -52,6 +53,7 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
// Optional params for exact calendar-based calculation
@@ -62,8 +64,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
if (dateStr != null && dateFormat != null) {
submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
}
} catch (Exception ignored) {
// intentionally ignored
} catch (Exception ignored) {
// intentionally ignored
}
Integer daysInYear = null;
final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
@@ -73,7 +75,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal maturityAmount;
if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) {
// Exact calendar-based calculation — same algorithm as account creation
final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths);
final LocalDate maturityDate = (tenureInDays != null && tenureInDays > 0) ? submittedOnDate.plusDays(tenureInDays)
: submittedOnDate.plusMonths(tenureInMonths);
maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate,
interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear);
} else {
@@ -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();
}
}
@@ -0,0 +1,496 @@
/**
* 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 com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
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.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Share transfer between two members.
*
* <p>
* A transfer is not a sale to the society and a purchase from it — no money reaches the society and
* its share capital does not change, so no GL entry is posted. What changes is the register: one
* member's holding falls, another's rises, by the same number on the same day at the same price.
* That is recorded as the two share transactions the rest of the module already understands
* (a redemption on the transferor, a purchase on the transferee), tied together by the transfer row
* so a later reader can tell it was one act rather than two unrelated dealings.
*
* <p>
* Maker-checker throughout: a transfer moves ownership and with it voting rights, which a
* co-operative does not leave to one clerk. Nothing moves until a checker approves, and every rule
* is re-checked at approval rather than trusted from submission — holdings move while a request
* waits, and a transfer that was valid on Monday can be impossible by Wednesday.
*/
@Path("/v1/sharetransfers")
@Component
@Tag(name = "Share Transfers", description = "Transfer of shares from one member to another, under board resolution.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareTransferApiResource {
private static final int TYPE_PURCHASED = 500;
private static final int TYPE_REDEEMED = 600;
private static final int TXN_APPROVED = 300;
private static final int ACCOUNT_ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ══════════════════════════════════════════════════════════════════════
// POST — a clerk records a transfer for approval
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
@Operation(summary = "Submit a share transfer for approval",
description = "Records a transfer of shares between two members. Nothing moves until a checker approves it.")
public Response submit(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject();
final Long fromAccountId = getLong(json, "fromShareAccountId");
final Long toAccountId = getLong(json, "toShareAccountId");
final Long shares = getLong(json, "shares");
final String transferDate = getString(json, "transferDate");
final String resolutionNumber = getString(json, "resolutionNumber");
final String meetingDate = getString(json, "meetingDate");
final String reason = getString(json, "reason");
if (fromAccountId == null || toAccountId == null) {
throw badRequest("error.msg.sharetransfer.accounts.required", "Both the transferring and receiving share accounts are required.");
}
if (shares == null || shares <= 0) {
throw badRequest("error.msg.sharetransfer.shares.invalid", "The number of shares to transfer must be greater than zero.");
}
// A co-operative transfers shares under a board resolution, so the resolution is part of the
// request rather than something captured afterwards — a transfer with no resolution behind it
// is the finding an auditor writes up.
if (resolutionNumber == null || resolutionNumber.isBlank()) {
throw badRequest("error.msg.sharetransfer.resolution.required",
"The board resolution authorising this transfer is required.");
}
if (meetingDate == null || meetingDate.isBlank()) {
throw badRequest("error.msg.sharetransfer.meeting.date.required", "The board meeting date is required.");
}
final LocalDate effectiveDate = transferDate == null || transferDate.isBlank()
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
: LocalDate.parse(transferDate);
final Map<String, Object> from = loadAccount(fromAccountId, "transferring");
final Map<String, Object> to = loadAccount(toAccountId, "receiving");
validateTransfer(from, to, shares);
final BigDecimal unitPrice = toDecimal(from.get("unit_price"));
final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares));
final String reference = nextReference();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final Long id = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_transfer
(transfer_reference, from_share_account_id, to_share_account_id, shares, unit_price, transfer_amount,
transfer_date, resolution_number, meeting_date, reason, status, maker_id, maker_name, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,'PENDING',?,?,?)
RETURNING id
""", Long.class, reference, fromAccountId, toAccountId, shares, unitPrice, amount,
java.sql.Date.valueOf(effectiveDate), resolutionNumber, java.sql.Date.valueOf(LocalDate.parse(meetingDate)), reason,
currentUser.getId(), currentUser.getDisplayName(), java.sql.Timestamp.valueOf(now));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("transferReference", reference);
out.put("status", "PENDING");
out.put("shares", shares);
out.put("amount", amount);
out.put("message", "Transfer " + reference + " recorded and sent for approval. Nothing moves until it is approved.");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — the transfer register
// ══════════════════════════════════════════════════════════════════════
@GET
@Operation(summary = "Share transfer register",
description = "Every share transfer with both members, the shares moved and the resolution behind it.")
public Response list(@QueryParam("status") final String status) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("""
SELECT st.id AS "transferId",
st.transfer_reference AS "transferReference",
to_char(st.transfer_date, 'YYYY-MM-DD') AS "transferDate",
st.shares AS "shares",
st.unit_price AS "unitPrice",
st.transfer_amount AS "transferAmount",
st.status AS "status",
st.resolution_number AS "resolutionNumber",
to_char(st.meeting_date, 'YYYY-MM-DD') AS "meetingDate",
st.reason AS "reason",
st.maker_name AS "makerName",
to_char(st.submitted_at, 'YYYY-MM-DD') AS "submittedOn",
st.checker_name AS "checkerName",
to_char(st.actioned_at, 'YYYY-MM-DD') AS "actionedOn",
st.rejection_reason AS "rejectionReason",
fa.id AS "fromShareAccountId",
fa.account_no AS "fromAccountNo",
fc.id AS "fromClientId",
fc.display_name AS "fromClientName",
fc.account_no AS "fromCifNumber",
ta.id AS "toShareAccountId",
ta.account_no AS "toAccountNo",
tc.id AS "toClientId",
tc.display_name AS "toClientName",
tc.account_no AS "toCifNumber",
sp.name AS "productName",
fa.currency_code AS "currencyCode"
FROM m_share_transfer st
JOIN m_share_account fa ON fa.id = st.from_share_account_id
JOIN m_client fc ON fc.id = fa.client_id
JOIN m_share_account ta ON ta.id = st.to_share_account_id
JOIN m_client tc ON tc.id = ta.client_id
LEFT JOIN m_share_product sp ON sp.id = fa.product_id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status)) {
sql.append(" AND st.status = ? ");
args.add(status);
}
sql.append(" ORDER BY st.submitted_at DESC, st.id DESC ");
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
long transferredShares = 0L;
BigDecimal transferredValue = BigDecimal.ZERO;
int pending = 0;
for (final Map<String, Object> row : rows) {
if ("APPROVED".equals(row.get("status"))) {
transferredShares += toLong(row.get("shares"));
transferredValue = transferredValue.add(toDecimal(row.get("transferAmount")));
} else if ("PENDING".equals(row.get("status"))) {
pending++;
}
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
final Map<String, Object> totals = new LinkedHashMap<>();
totals.put("transferCount", rows.size());
totals.put("pendingCount", pending);
totals.put("sharesTransferred", transferredShares);
totals.put("valueTransferred", transferredValue);
out.put("totals", totals);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /{id}/approve — the transfer actually happens here
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{transferId}/approve")
@Transactional
@Operation(summary = "Approve a share transfer",
description = "Re-checks every rule against today's holdings, then moves the shares: a redemption on the transferor and a purchase on the transferee.")
public Response approve(@PathParam("transferId") final Long transferId) {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> transfer = loadTransfer(transferId);
if (!"PENDING".equals(transfer.get("status"))) {
throw badRequest("error.msg.sharetransfer.not.pending",
"Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + ".");
}
final Long fromAccountId = toLong(transfer.get("from_share_account_id"));
final Long toAccountId = toLong(transfer.get("to_share_account_id"));
final long shares = toLong(transfer.get("shares"));
// Re-checked, not trusted: holdings move while a request waits, and a transfer that was
// valid when it was raised can be impossible by the time it reaches a checker.
final Map<String, Object> from = loadAccount(fromAccountId, "transferring");
final Map<String, Object> to = loadAccount(toAccountId, "receiving");
validateTransfer(from, to, shares);
final BigDecimal unitPrice = toDecimal(transfer.get("unit_price"));
final BigDecimal amount = toDecimal(transfer.get("transfer_amount"));
final java.sql.Date effectiveDate = (java.sql.Date) transfer.get("transfer_date");
final Object meetingDate = transfer.get("meeting_date");
final String resolutionNumber = (String) transfer.get("resolution_number");
// The transferor is redeemed and the transferee purchases — the same two movements the rest
// of the share module already reads, so every balance, statement and report picks this up
// without knowing transfers exist.
final Long fromTxnId = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_account_transactions
(account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active,
resolution_number, meeting_date)
VALUES (?,?,?,?,?,?,?,true,?,?)
RETURNING id
""", Long.class, fromAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_REDEEMED,
resolutionNumber, meetingDate);
final Long toTxnId = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_account_transactions
(account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active,
resolution_number, meeting_date)
VALUES (?,?,?,?,?,?,?,true,?,?)
RETURNING id
""", Long.class, toAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_PURCHASED,
resolutionNumber, meetingDate);
// The account's own running total is kept in step with the transactions just written, since
// the rest of the platform reads it directly.
this.jdbcTemplate.update(
"UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) - ? WHERE id = ?", shares,
fromAccountId);
this.jdbcTemplate.update(
"UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) + ? WHERE id = ?", shares,
toAccountId);
this.jdbcTemplate.update("""
UPDATE m_share_transfer SET status = 'APPROVED', from_transaction_id = ?, to_transaction_id = ?,
checker_id = ?, checker_name = ?, actioned_at = ?
WHERE id = ?
""", fromTxnId, toTxnId, currentUser.getId(), currentUser.getDisplayName(),
java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", transferId);
out.put("transferReference", transfer.get("transfer_reference"));
out.put("status", "APPROVED");
out.put("shares", shares);
out.put("amount", amount);
out.put("fromTransactionId", fromTxnId);
out.put("toTransactionId", toTxnId);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /{id}/reject
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{transferId}/reject")
@Transactional
@Operation(summary = "Reject a share transfer")
public Response reject(@PathParam("transferId") final Long transferId, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject();
final Map<String, Object> transfer = loadTransfer(transferId);
if (!"PENDING".equals(transfer.get("status"))) {
throw badRequest("error.msg.sharetransfer.not.pending",
"Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + ".");
}
this.jdbcTemplate.update("""
UPDATE m_share_transfer SET status = 'REJECTED', rejection_reason = ?, checker_id = ?, checker_name = ?,
actioned_at = ?
WHERE id = ?
""", getString(json, "rejectionReason"), currentUser.getId(), currentUser.getDisplayName(),
java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", transferId);
out.put("status", "REJECTED");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Every rule a transfer has to satisfy, applied identically at submission and at approval.
*
* <p>
* Holdings are derived from the transactions rather than read off the account's running total,
* for the same reason the balance report does it: the total is what the account currently says,
* and this needs what the movements actually add up to.
*/
private void validateTransfer(final Map<String, Object> from, final Map<String, Object> to, final long shares) {
final Long fromAccountId = toLong(from.get("id"));
final Long toAccountId = toLong(to.get("id"));
if (fromAccountId.equals(toAccountId)) {
throw badRequest("error.msg.sharetransfer.same.account", "A share account cannot transfer to itself.");
}
if (toLong(from.get("client_id")) == toLong(to.get("client_id"))) {
throw badRequest("error.msg.sharetransfer.same.member",
"Both accounts belong to the same member, so there is nothing to transfer between.");
}
// Different share classes carry different rights and prices; moving shares across them would
// be an issue and a redemption, not a transfer.
if (toLong(from.get("product_id")) != toLong(to.get("product_id"))) {
throw badRequest("error.msg.sharetransfer.product.mismatch",
"Shares can only be transferred within the same share class. " + from.get("product_name") + " and "
+ to.get("product_name") + " are different classes.");
}
if (toInt(from.get("status_enum")) != ACCOUNT_ACTIVE) {
throw badRequest("error.msg.sharetransfer.from.not.active",
"Share account " + from.get("account_no") + " is not active, so it cannot transfer shares.");
}
if (toInt(to.get("status_enum")) != ACCOUNT_ACTIVE) {
throw badRequest("error.msg.sharetransfer.to.not.active",
"Share account " + to.get("account_no") + " is not active, so it cannot receive shares.");
}
final long fromHeld = heldShares(fromAccountId);
final long toHeld = heldShares(toAccountId);
if (fromHeld < shares) {
throw badRequest("error.msg.sharetransfer.insufficient.shares", "Share account " + from.get("account_no") + " holds "
+ fromHeld + " shares, which is fewer than the " + shares + " being transferred.");
}
final Long maximum = toLongOrNull(to.get("maximum_client_shares"));
if (maximum != null && toHeld + shares > maximum) {
throw badRequest("error.msg.sharetransfer.above.maximum",
"The transfer would leave " + to.get("to_client_name") + " holding " + (toHeld + shares)
+ " shares, above the " + maximum + " this share class allows one member.");
}
// A transferor may go to nil — that is a member leaving — but may not be left holding less
// than the class's minimum, which is a membership the society cannot recognise.
final Long minimum = toLongOrNull(from.get("minimum_client_shares"));
final long remaining = fromHeld - shares;
if (minimum != null && remaining > 0 && remaining < minimum) {
throw badRequest("error.msg.sharetransfer.below.minimum",
"The transfer would leave " + from.get("account_no") + " holding " + remaining + " shares, below the "
+ minimum + " minimum this share class requires. Transfer all " + fromHeld
+ " shares, or leave at least " + minimum + ".");
}
}
/**
* What the account's settled movements actually add up to, ignoring its running total.
*
* <p>
* Only paid-up shares can be transferred. An opening tranche whose collection has never been
* applied is shares allotted against money the society never received — the member does not yet
* own them to give away, and transferring them would move an unpaid holding onto somebody else's
* name. The account's own {@code total_approved_shares} counts them, which is exactly why this
* walks the transactions instead.
*/
private long heldShares(final Long shareAccountId) {
final Long held = this.jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END)
- SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END), 0)
FROM m_share_account_transactions t
JOIN m_share_account pa ON pa.id = t.account_id
WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?)
AND (t.type_enum = ? OR t.funding_source IS NOT NULL OR pa.collection_applied = true)
""", Long.class, TYPE_PURCHASED, TYPE_REDEEMED, shareAccountId, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED,
TYPE_REDEEMED);
return held == null ? 0L : held;
}
private Map<String, Object> loadAccount(final Long shareAccountId, final String side) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
c.display_name AS to_client_name, c.account_no AS cif_number,
sp.name AS product_name, sp.unit_price, sp.minimum_client_shares, sp.maximum_client_shares
FROM m_share_account sa
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_share_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", shareAccountId);
if (rows.isEmpty()) {
throw badRequest("error.msg.sharetransfer.account.not.found",
"The " + side + " share account " + shareAccountId + " was not found.");
}
return rows.get(0);
}
private Map<String, Object> loadTransfer(final Long transferId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_share_transfer WHERE id = ?", transferId);
if (rows.isEmpty()) {
throw badRequest("error.msg.sharetransfer.not.found", "Transfer " + transferId + " was not found.");
}
return rows.get(0);
}
/** TRF-YYMMDD-NNNN, dated and serial, so a reference says when it was raised. */
private String nextReference() {
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String prefix = "TRF-" + today.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + "-";
final Integer used = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_transfer WHERE transfer_reference LIKE ?", Integer.class, prefix + "%");
return prefix + String.format("%04d", (used == null ? 0 : used) + 1);
}
private PlatformDataIntegrityException badRequest(final String code, final String message) {
return new PlatformDataIntegrityException(code, message);
}
private Long getLong(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
}
private String getString(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
private long toLong(final Object value) {
return value instanceof Number number ? number.longValue() : 0L;
}
private Long toLongOrNull(final Object value) {
return value instanceof Number number ? number.longValue() : null;
}
private int toInt(final Object value) {
return value instanceof Number number ? number.intValue() : 0;
}
private BigDecimal toDecimal(final Object value) {
if (value instanceof BigDecimal decimal) {
return decimal;
}
return value instanceof Number number ? BigDecimal.valueOf(number.doubleValue()) : BigDecimal.ZERO;
}
}
@@ -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
@@ -166,6 +166,8 @@ public class ShareAccountDataSerializer {
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull()
.longGreaterThanZero();
validateNoOpenAccountForProduct(clientId, productId, baseDataValidator);
if (shareProduct.getMinimumClientShares() != null && requestedShares < shareProduct.getMinimumClientShares()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).failWithCode(
"client.can.not.purchase.shares.lessthan.product.definition",
@@ -238,6 +240,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 +336,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)) {
@@ -529,6 +547,43 @@ public class ShareAccountDataSerializer {
return actualChanges;
}
/**
* A member may hold only one share account per product at a time.
*
* The society's shareholding in a product is one relationship, not several: a member's stake, their dividend and
* their voting entitlement are all read from it, so a second open account splits the same person's holding across
* two records and makes each of those figures ambiguous. It also lets the product's maximum-shares-per-client
* ceiling be passed by opening a second account, since that check only ever sees the account in front of it.
*
* Closed and rejected accounts do not block a new one. A member who closed their account and later rejoins is
* starting a fresh relationship, and a rejected application never became one — neither leaves a holding behind.
* Everything else does: an account still awaiting approval, or approved but not yet paid for, is a commitment the
* society has already recorded, so a second application on top of it is refused rather than quietly allowed.
*/
private void validateNoOpenAccountForProduct(final Long clientId, final Long productId,
final DataValidatorBuilder baseDataValidator) {
if (clientId == null || productId == null) {
return;
}
// 500 REJECTED and 600 CLOSED are the two states that leave no holding behind; see
// ShareAccountStatusType for the full set.
final List<Map<String, Object>> open = this.jdbcTemplate.queryForList("""
SELECT account_no, status_enum FROM m_share_account
WHERE client_id = ? AND product_id = ? AND status_enum NOT IN (500, 600)
ORDER BY id
""", clientId, productId);
if (open.isEmpty()) {
return;
}
// The existing account number is named so the officer can go and look at it, rather than
// being told only that one exists somewhere.
final String existing = open.get(0).get("account_no") == null ? "" : String.valueOf(open.get(0).get("account_no"));
baseDataValidator.reset().parameter(ShareAccountApiConstants.productid_paramname).value(productId).failWithCode(
"client.already.has.an.open.share.account.for.this.product",
"This member already holds share account " + existing
+ " in this product. Close that account before opening another, or add shares to it instead.");
}
private void validateTotalSubsribedShares(final ShareAccount account, final ShareAccountTransaction transaction,
final DataValidatorBuilder baseDataValidator) {
Long totalSubsribedShares = account.getShareProduct().getSubscribedShares();
@@ -732,6 +787,16 @@ public class ShareAccountDataSerializer {
if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) {
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state");
}
// An Active account can still be an unpaid application -- activation posts no GL of its own,
// only Apply Collection does. A further tranche on top of a first allotment nobody has paid
// for yet would grow a holding that does not exist: settle the original application first,
// then add to it. Read straight from the column rather than through the entity, which does
// not map it -- collection_applied lives outside JPA, set only by the collection endpoints.
final Boolean collectionApplied = this.jdbcTemplate
.queryForObject("SELECT collection_applied FROM m_share_account WHERE id = ?", Boolean.class, account.getId());
if (!Boolean.TRUE.equals(collectionApplied)) {
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("original.application.not.yet.settled");
}
LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull();
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
@@ -783,6 +848,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 +925,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);
@@ -859,25 +933,23 @@ public class ShareAccountDataSerializer {
// already used" does not say which one to go and look at.
final Object label = collection.get("voucher_no") != null ? collection.get("voucher_no") : collection.get("receipt_no");
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",
"Voucher " + label + " belongs to a different member.");
}
// No client-match check here, matching the same product decision already applied to the
// first-application Apply Collection flow: a voucher is reachable and attachable from any
// account's screen (see ShareAdvanceCollectionApiResource), so rejecting it again here,
// at the very last step, would only fail a submission the counter was told was valid.
//
// Only money still sitting in Sundry can fund a tranche. APPLIED has already become
// share capital somewhere, and REFUNDED went back to the member.
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 +963,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 +1281,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 +1391,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,49 +122,210 @@ 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
// become share capital too, and one left UNAPPLIED could be spent a second time.
//
// client_id is rewritten to this account's own client here too -- a voucher can now be found
// and applied from any member's screen, so whoever last searched it (the value it carried
// going in) is not necessarily who its money actually became share capital for. Leaving the
// old value in place would make the receipt's own record misname whose it is the moment it
// settles, which is exactly the figure a receipt-detail popup reads back afterwards.
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
this.jdbcTemplate.update("""
UPDATE m_share_advance_collection
SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ?
SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ?, client_id = ?
WHERE id = ? AND status = 'UNAPPLIED'
""", account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), collectionId);
""", account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), account.getClientId(), collectionId);
}
}
/**
* 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 +334,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 +350,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 +490,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 +504,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 +527,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 +565,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);
}
@@ -35,15 +35,21 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
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.commands.service.SynchronousCommandProcessingService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -72,6 +78,7 @@ import org.springframework.stereotype.Component;
@Component
@Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.")
@RequiredArgsConstructor
@Slf4j
@Produces({ MediaType.APPLICATION_JSON })
public class AllProductsDividendApiResource {
@@ -96,6 +103,7 @@ public class AllProductsDividendApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── GET /v1/shareproducts/dividends ── Every declaration, across products ──
@@ -176,6 +184,8 @@ public class AllProductsDividendApiResource {
entry.put("productName", capital.productName);
entry.put("weightedCapital", capital.weightedCapital);
entry.put("amount", amount);
entry.put("memberCount", capital.memberCount);
entry.put("accountCount", capital.accountCount);
entry.put("eligible", capital.weightedCapital.signum() > 0);
entry.put("reason", capital.weightedCapital.signum() > 0 ? null
: "No member has held shares in this product long enough to qualify for this period.");
@@ -191,6 +201,21 @@ public class AllProductsDividendApiResource {
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
out.put("ratePercentage", ratePercentage);
out.put("totalWeightedCapital", totalWeightedCapital);
// Distinct across products: one person holding shares in both is one member receiving a
// dividend, not two, so summing the per-product counts would overstate the membership.
out.put("totalMemberCount", distinctMemberCount(from, to));
// Accounts simply add up, unlike members: each holding is allocated, locked and claimed on
// its own, so a person with two of them appears twice here and once in the member count.
out.put("totalAccountCount", capitals.stream().mapToInt(c -> c.accountCount).sum());
// Reported rather than thrown, so the officer sees why the year they picked cannot be
// declared while the figures are still on screen. The POST enforces the same rule.
out.put("periodNotFinished", !to.isBefore(DateUtils.getBusinessLocalDate()));
// The board's own cap, reported so the screen can flag a rate above it while it can still be
// changed. The POST enforces the same limit.
final BigDecimal rateCeiling = configuredRateCeiling();
out.put("maximumRatePercentage", rateCeiling);
out.put("exceedsMaximumRate",
rateCeiling != null && rateCeiling.signum() > 0 && ratePercentage != null && ratePercentage.compareTo(rateCeiling) > 0);
out.put("profitGlCode", distributableProfitGlCode());
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
// visible on screen before submission — the command layer enforces the same ceiling, and an
@@ -238,6 +263,8 @@ public class AllProductsDividendApiResource {
if (rate.signum() <= 0) {
throw apiError(400, "ratePercentage must be greater than zero");
}
rejectIfPeriodNotFinished(to);
rejectIfRateAboveCeiling(rate);
final List<ProductCapital> capitals = capitalPerProduct(from, to);
@@ -276,8 +303,27 @@ public class AllProductsDividendApiResource {
payload.addProperty("dividendAmount", amount);
try {
// Each product gets its own idempotency key. Without one, every command in this loop
// inherits the single key held against the HTTP request, so the first product's
// declaration claims it and every product after is rejected as already processed --
// the second product silently received nothing while the first went through.
//
// Freshly generated per command rather than derived from the period and rate: a key
// built from those is the same on every run, so once a declaration had been made and
// then reverted, redeclaring the same year at the same rate was refused as a
// duplicate of the reverted one. Declaring twice for a period is already prevented
// properly, by the overlap check against existing declarations, which looks at what
// is actually on the books rather than at what was once submitted.
final String idempotencyKey = java.util.UUID.randomUUID().toString();
// The command layer records the command it just ran against the HTTP request, and
// treats any command that follows within the same request as a retry of it —
// reusing the first command's idempotency key and rejecting the rest as already
// processed. Cleared before each product so every declaration in this loop is
// executed on its own terms rather than as a repeat of the one before it.
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId)
.withJson(payload.toString()).build();
.withJson(payload.toString()).build(idempotencyKey);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command);
// The rate the board actually resolved, recorded as declared. Written here rather
@@ -296,6 +342,10 @@ public class AllProductsDividendApiResource {
row.put("dividendId", result.getSubResourceId());
created.add(row);
} catch (final RuntimeException e) {
// Logged with the stack trace: the reason reported below comes from the exception's
// message, and several of the failures here carry none, leaving "Could not declare
// for this product" as the only trace of a product that silently received nothing.
log.error("Dividend declaration failed for product {} ({})", productId, productName, e);
// Most often the distributable-profit ceiling: an earlier product in this same run has
// already consumed the pool. Reported per product so the officer can see which ones
// went through and why the rest did not, instead of losing all of them to one failure.
@@ -319,31 +369,56 @@ public class AllProductsDividendApiResource {
// ── Helpers ────────────────────────────────────────────────────────────
/** One product's weighted paid-up capital for the period. */
/** One product's weighted paid-up capital for the period, and how many members it covers. */
private static final class ProductCapital {
private final Long productId;
private final String productName;
private final BigDecimal weightedCapital;
/**
* Members whose holdings make up that capital.
*
* Carried so the preview can say who a product's amount is actually spread across. An amount
* alone does not tell the officer whether it reaches the membership they expect — a product
* showing a plausible total against one member is the case worth catching before approval,
* not after.
*/
private final int memberCount;
/**
* Accounts that capital is spread across.
*
* Tracked alongside the member count because one person may hold shares in more than one
* product, and each holding earns and is claimed separately. The two numbers differ exactly
* when that happens, so reporting both is what tells an officer the split is intentional
* rather than a miscount.
*/
private final int accountCount;
private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) {
private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital,
final int memberCount, final int accountCount) {
this.productId = productId;
this.productName = productName;
this.weightedCapital = weightedCapital;
this.memberCount = memberCount;
this.accountCount = accountCount;
}
}
/** Weighted paid-up capital for every share product, in one pass. */
private List<ProductCapital> capitalPerProduct(final LocalDate from, final LocalDate to) {
final List<ProductCapital> capitals = new ArrayList<>();
for (final Map<String, Object> product : jdbcTemplate
.queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) {
for (final Map<String, Object> product : jdbcTemplate.queryForList(
"SELECT id, name, minimum_active_period_frequency, allow_dividends_inactive_clients FROM m_share_product ORDER BY id")) {
final Long productId = ((Number) product.get("id")).longValue();
final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0
: new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue();
final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod);
final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients"));
final Map<String, Object> holding = weightedCapitalDays(productId, from, to, minimumActivePeriod, allowInactiveClients);
final BigDecimal capitalDays = (BigDecimal) holding.get("capital_days");
final int memberCount = ((Number) holding.get("member_count")).intValue();
final int accountCount = ((Number) holding.get("account_count")).intValue();
capitals.add(new ProductCapital(productId, (String) product.get("name"),
capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP)));
capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP), memberCount, accountCount));
}
return capitals;
}
@@ -466,19 +541,144 @@ public class AllProductsDividendApiResource {
* measured to the day after it. Without it a full financial year counts as 364 days and a product asking for the
* obvious 365 would qualify nobody — and this preview would disagree with the declaration it is previewing.
*/
private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to,
final int minimumActivePeriod) {
final BigDecimal total = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0)
private Map<String, Object> weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to,
final int minimumActivePeriod, final boolean allowInactiveClients) {
// A closed account still earned dividend for the part of the period it was open, so when the
// product allows inactive clients it is included exactly as the declaration includes it --
// ACTIVE, or CLOSED after the period began. Filtering to ACTIVE alone was what made this
// preview disagree with the declaration it previews: a product whose members had since closed
// their accounts showed a smaller capital here than the allocation actually paid out on, and
// the rate the officer saw was not the rate the members received.
final String statusClause = allowInactiveClients
? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) "
: " AND sa.status_enum = 300 ";
final List<Object> params = new ArrayList<>();
params.add(java.sql.Date.valueOf(to));
params.add(java.sql.Date.valueOf(from));
params.add(productId);
if (allowInactiveClients) {
params.add(java.sql.Date.valueOf(from));
}
params.add(java.sql.Date.valueOf(to));
params.add(java.sql.Date.valueOf(to));
params.add(java.sql.Date.valueOf(from));
params.add(minimumActivePeriod);
// Capital and member count come out of one query against one set of rows, so the two can
// never describe different populations -- a count derived separately would drift from the
// amount it is meant to explain the moment either query's filters were touched.
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0) AS capital_days,
COUNT(DISTINCT sa.client_id) AS member_count,
COUNT(DISTINCT sa.id) AS account_count
FROM m_share_account sa
JOIN m_share_account_transactions t ON t.account_id = sa.id
WHERE sa.product_id = ? AND sa.status_enum = 300
WHERE sa.product_id = ?
""" + statusClause + """
AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true
AND COALESCE(t.amount_paid, 0) > 0
AND t.transaction_date <= ?
AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ?
""", BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to),
java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod);
return total == null ? BigDecimal.ZERO : total;
""", params.toArray());
return row;
}
/**
* Refuses a declaration for a period that has not finished yet.
*
* A dividend is a share of the year's profit, and until the last day of the period has passed
* that profit is not a settled figure — declaring against it commits the society to paying out of
* a number that can still move with every transaction booked before 31 March. Each member's
* entitlement is also weighted by how long they held shares across the period, which cannot be
* known while days remain in it: a member joining in February would be paid as though the year
* ended today.
*
* Compared against the business date, not the server clock, so a branch operating on a back-dated
* business date is held to that date.
*/
private void rejectIfPeriodNotFinished(final LocalDate to) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (!to.isBefore(businessDate)) {
throw apiError(400, "The period ending " + to + " has not finished yet — a dividend can only be declared once the "
+ "financial year is over and its profit is final. The current business date is " + businessDate + ".");
}
}
/**
* Refuses a rate above the society's configured ceiling.
*
* {@code dividend-maximum-rate-percentage} is the board's own cap on what may be paid in a year —
* a co-operative society's bye-laws set one, and exceeding it needs a resolution rather than a
* typed figure. It was read only by the distributable-profit screen, which reported it without
* anything enforcing it, so a rate above the cap went through unchallenged.
*
* A missing or disabled configuration row means the society has set no ceiling, which is not a
* failure — the rate then stands on the profit ceiling alone.
*/
private void rejectIfRateAboveCeiling(final BigDecimal rate) {
final BigDecimal ceiling = configuredRateCeiling();
if (ceiling != null && ceiling.signum() > 0 && rate.compareTo(ceiling) > 0) {
throw apiError(400, "A rate of " + rate.stripTrailingZeros().toPlainString() + "% is above the society's maximum of "
+ ceiling.stripTrailingZeros().toPlainString() + "%. Lower the rate, or raise the limit in "
+ "Configurations (dividend-maximum-rate-percentage) if the board has resolved a higher one.");
}
}
/** The society's configured maximum rate, or null when no ceiling is set. */
private BigDecimal configuredRateCeiling() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT value FROM c_configuration WHERE name = 'dividend-maximum-rate-percentage' AND enabled = true");
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return null;
}
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
}
/**
* How many distinct members this rate would reach across every product.
*
* Each product is measured under its own rules — a product's minimum active period and whether it
* pays inactive clients both change who qualifies — and the resulting client ids are then unioned,
* so somebody holding shares in two products counts once. Summing the per-product counts instead
* would report more members than exist.
*/
private int distinctMemberCount(final LocalDate from, final LocalDate to) {
final Set<Long> members = new HashSet<>();
for (final Map<String, Object> product : jdbcTemplate.queryForList(
"SELECT id, minimum_active_period_frequency, allow_dividends_inactive_clients FROM m_share_product")) {
final Long productId = ((Number) product.get("id")).longValue();
final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0
: new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue();
final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients"));
final String statusClause = allowInactiveClients
? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) "
: " AND sa.status_enum = 300 ";
final List<Object> params = new ArrayList<>();
params.add(productId);
if (allowInactiveClients) {
params.add(java.sql.Date.valueOf(from));
}
params.add(java.sql.Date.valueOf(to));
params.add(java.sql.Date.valueOf(to));
params.add(java.sql.Date.valueOf(from));
params.add(minimumActivePeriod);
members.addAll(jdbcTemplate.queryForList("""
SELECT DISTINCT sa.client_id
FROM m_share_account sa
JOIN m_share_account_transactions t ON t.account_id = sa.id
WHERE sa.product_id = ?
""" + statusClause + """
AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true
AND COALESCE(t.amount_paid, 0) > 0
AND t.transaction_date <= ?
AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ?
""", Long.class, params.toArray()));
}
return members.size();
}
private boolean hasDeclarationFor(final Long productId, final LocalDate from, final LocalDate to) {
@@ -59,8 +59,13 @@ public class ShareProductDividendAssembler {
ShareProductData product = (ShareProductData) this.shareProductReadPlatformService.retrieveOne(productId, false);
MonetaryCurrency currency = new MonetaryCurrency(product.getCurrency().getCode(), product.getCurrency().getDecimalPlaces(),
product.getCurrency().getInMultiplesOf());
Collection<ShareAccountData> shareAccountDatas = this.shareAccountReadPlatformService.retrieveAllShareAccountDataForDividends(
productId, product.getAllowDividendCalculationForInactiveClients(), dividendPeriodStartDate);
// Defaulted rather than unboxed directly: the flag is a nullable Boolean, and a product
// saved before the column existed carries null, which throws a NullPointerException on
// unboxing. That exception surfaced as "Could not declare for this product" with no reason
// attached, dropping the product from the run while the others went through.
final boolean allowInactiveClients = Boolean.TRUE.equals(product.getAllowDividendCalculationForInactiveClients());
Collection<ShareAccountData> shareAccountDatas = this.shareAccountReadPlatformService
.retrieveAllShareAccountDataForDividends(productId, allowInactiveClients, dividendPeriodStartDate);
if (shareAccountDatas == null || shareAccountDatas.isEmpty()) {
throw new ShareAccountsNotFoundException(product.getId());
}
@@ -83,6 +88,15 @@ public class ShareProductDividendAssembler {
dividendPeriodStartDate, dividendPeriodEndDate);
for (ShareAccountData accountData : shareAccountDatas) {
long numberOfShareDaysPerAccount = numberOfSharesdaysPerAccount.get(accountData.getId());
// No share-days means the account held nothing inside this period at all -- its
// purchase falls after the period end, or never cleared the minimum active period,
// or was never paid for. Such an account is not a member of this dividend, so it
// gets no row: listing it would put a nil entitlement in front of a checker, count
// it in the declaration's member total, and report it as "below the minimum payable
// amount" when the truth is that it does not belong to this year at all.
if (numberOfShareDaysPerAccount <= 0) {
continue;
}
double amountForAccount = numberOfShareDaysPerAccount * amountPerShareDay;
// Rounded DOWN to a multiple of the configured figure — 546.789 becomes 545, 121.87
// becomes 120 at a multiple of 5. A branch pays this out as a Demand Draft and counts
@@ -150,7 +164,26 @@ public class ShareProductDividendAssembler {
final PurchasedSharesStatusType type = PurchasedSharesStatusType.fromInt(purchasedSharesData.getType().getId().intValue());
if (status.isApproved() && !type.isChargePayment()) {
// An approved purchase the member never paid for earns nothing. Approval is the
// society agreeing to allot the shares; the dividend is a return on capital that
// was actually brought in, so shares still awaiting payment are not capital yet.
// Counting them would pay a member out of everyone else's money for a holding
// they have not funded, and would dilute every paid-up member's entitlement.
if (type.isPurchased() && (purchasedSharesData.getAmountPaid() == null
|| purchasedSharesData.getAmountPaid().signum() <= 0)) {
continue;
}
LocalDate shareStartDate = purchasedSharesData.getPurchasedDate();
// A purchase made after the period ends belongs to a later year and has no
// holding inside this one. Left in, it is measured from its own date back to the
// period end — a negative span — and those negative share-days are subtracted
// from the product's total, shrinking every member's entitlement to pay for
// shares nobody held during the year. A 20-May-2026 purchase counted -2,450
// share-days against FY 2025-26 here.
if (DateUtils.isAfter(shareStartDate, postingDate)) {
continue;
}
if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) {
shareStartDate = lastDividendPostDate;
}
@@ -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;
@@ -186,6 +188,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final BigDecimal dividendAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
validatePeriodHasFinished(dividendPeriodEndDate);
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
validateWithinDistributableProfit(productId, dividendPeriodStartDate, dividendPeriodEndDate, dividendAmount);
final String financialYear = financialYearOf(dividendPeriodEndDate);
@@ -225,6 +228,26 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
* that period's profit. Two declarations covering the same days would pay members twice for the same period, so any
* overlap against an existing declaration for this product is blocked outright.
*/
/**
* A dividend may only be declared once the period it covers is over.
*
* The payout is a share of that period's profit, and until the last day has passed the profit is
* not final — every transaction still to be booked moves the figure the declaration was sized on.
* Each member's entitlement is weighted by how long they held shares across the period too, which
* cannot be settled while days remain in it.
*
* Enforced here rather than only on the screen because this command is the single point every
* declaration passes through, including one posted straight to the API.
*/
private void validatePeriodHasFinished(final LocalDate dividendPeriodEndDate) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (!dividendPeriodEndDate.isBefore(businessDate)) {
throw new DividentProcessingException("period.not.finished",
"The period ending " + dividendPeriodEndDate + " has not finished yet, so its profit is not final. A dividend can "
+ "only be declared once the financial year is over. The current business date is " + businessDate + ".");
}
}
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
final LocalDate dividendPeriodEndDate) {
final Integer overlappingCount = jdbcTemplate.queryForObject("""
@@ -298,13 +321,35 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount();
final Long settledAccountId = dividendGlAccounts.retainedProfitAccount();
// The year's leftover profit is closed off once, not once per product.
//
// Every product's approval arrives here and computes the same society-wide remainder, so
// approving a second product for the same year moved that remainder to Bank Profit a second
// time -- 73,454.80 went across twice and left the Non-Distributable account holding a debit
// balance, money the society had swept out but never had. Each transaction balanced on its
// own, so the trial balance stayed at zero and hid it.
//
// Detected from the ledger rather than a flag on the declaration, because the entry is what
// actually moved the money: a close-off already posted for this financial year means this
// year's remainder is settled, whichever product's approval posted it.
final String financialYearLabel = financialYearOf(periodEndDate);
final Integer alreadyClosedOff = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM acc_gl_journal_entry
WHERE reversed = false AND account_id = ?
AND description LIKE ?
""", Integer.class, distributableAccountId, "FY " + financialYearLabel + " undistributed profit closed off%");
if (alreadyClosedOff != null && alreadyClosedOff > 0) {
return;
}
final String financialYear = financialYearOf(periodEndDate);
// Dated to the day of approval, not the period end. Closing off the remainder is an act of
// the approval, so it must appear in the ledger on the day the officer performed it —
// 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 +451,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 +600,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 +647,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 +758,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));
@@ -27,13 +27,17 @@ 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.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -56,6 +60,7 @@ public class SponsorBankApiResource {
private final SponsorBankRepository sponsorBankRepository;
private final PlatformSecurityContext context;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */
@@ -63,31 +68,103 @@ public class SponsorBankApiResource {
try {
return action.get();
} catch (final IllegalArgumentException | IllegalStateException e) {
final JsonObject body = new JsonObject();
body.addProperty("message", e.getMessage());
body.addProperty("defaultUserMessage", e.getMessage());
body.addProperty("httpStatus", 400);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", e.getMessage());
errors.add(err);
body.add("errors", errors);
throw new WebApplicationException(
Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
throw badRequest(e.getMessage());
} catch (final org.springframework.dao.DataIntegrityViolationException e) {
// A constraint the form cannot pre-empt - in practice the unique bank name. Reported as
// the 400 it is, rather than surfacing as an "Internal Server Error" that tells an
// administrator nothing about what to change.
final String detail = e.getMostSpecificCause().getMessage();
throw badRequest(detail != null && detail.toLowerCase().contains("name")
? "A sponsor bank with this name already exists. Bank names must be unique."
: "This sponsor bank could not be saved: " + detail);
} catch (final org.springframework.jdbc.BadSqlGrammarException e) {
// Almost always a pending schema change: the DD Payable column arrives with changelog
// 0568, and a server started before it was applied has no such column. Said plainly so
// the fix (restart/migrate) is obvious rather than looking like a code fault.
throw badRequest("The sponsor bank tables are not up to date with this build - the DD Payable column is missing."
+ " Restart the server so the pending database migrations are applied, then try again.");
}
}
private WebApplicationException badRequest(final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", 400);
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(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve all Sponsor Banks", description = "Returns a list of all registered Sponsor Banks.")
public Collection<SponsorBankData> retrieveAllSponsorBanks() {
// The payable account's code and name travel with each bank so the list can show which GL a
// bank's drafts are credited to, and flag a bank that has none, without a second round trip.
final Map<Long, String[]> glNames = loadGlAccountNames();
return this.sponsorBankRepository.findAll().stream()
.map(sb -> new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(),
sb.getSwiftCode()))
.map(sb -> {
final String[] gl = sb.getDdPayableGlAccountId() == null ? null : glNames.get(sb.getDdPayableGlAccountId());
return new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(),
sb.getSwiftCode(), sb.getDdPayableGlAccountId(), gl == null ? null : gl[0], gl == null ? null : gl[1]);
})
.toList();
}
private Map<Long, String[]> loadGlAccountNames() {
final Map<Long, String[]> names = new java.util.HashMap<>();
for (final Map<String, Object> row : this.jdbcTemplate.queryForList("SELECT id, gl_code, name FROM acc_gl_account")) {
names.put(((Number) row.get("id")).longValue(), new String[] { (String) row.get("gl_code"), (String) row.get("name") });
}
return names;
}
/**
* Rejects a payable account that cannot serve as one.
*
* A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be
* a liability account, and it has to accept postings. Catching that here means an administrator
* is told while choosing the account, rather than a clerk discovering it when a draft fails.
*/
private void validateDdPayableGlAccount(final Long glAccountId) {
if (glAccountId == null) {
return;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?",
glAccountId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("The selected DD Payable GL account does not exist.");
}
final Map<String, Object> gl = rows.get(0);
final String label = gl.get("gl_code") + " · " + gl.get("name");
// 2 is LIABILITY in Fineract's GLAccountType enum.
if (((Number) gl.get("classification_enum")).intValue() != 2) {
throw new IllegalArgumentException(
"DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. "
+ label + " is not a liability account.");
}
// 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header.
if (((Number) gl.get("account_usage")).intValue() == 2) {
throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under "
+ label + ".");
}
// Read through both shapes: the column comes back as a Boolean on some drivers and as a
// 0/1 Number on others, and a plain Boolean cast would throw on the latter.
final Object disabled = gl.get("disabled");
final boolean isDisabled = disabled instanceof Boolean b ? b
: disabled instanceof Number n && n.intValue() != 0;
if (isDisabled) {
throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled.");
}
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -99,16 +176,63 @@ public class SponsorBankApiResource {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Sponsor Bank Name is required.");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
final AppUser currentUser = this.context.authenticatedUser();
final SponsorBank sponsorBank = new SponsorBank(
request.getName(),
request.getDescription(),
request.getIfscCode() != null ? request.getIfscCode().trim().toUpperCase() : null,
request.getBankCode(),
request.getSwiftCode() != null ? request.getSwiftCode().trim().toUpperCase() : null,
currentUser,
LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())
);
// Set field by field rather than through the all-args constructor: that constructor is
// positional, so adding a column silently shifts every argument after it, and the shift
// type-checks whenever two neighbours share a type.
final SponsorBank sponsorBank = new SponsorBank();
sponsorBank.setName(request.getName().trim());
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
sponsorBank.setCreatedBy(currentUser);
sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
});
}
/**
* Edits a sponsor bank, which in practice is mostly about pointing it at its DD Payable account.
*
* Existing banks predate the per-bank payable and have to be given one before they can issue any
* further drafts, and an account repointed at the wrong GL has to be correctable without
* deleting a bank that inventory and issued drafts already reference.
*/
@PUT
@Path("{sponsorBankId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.")
public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId,
final SponsorBankRequest request) {
return safely(() -> {
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
if (request.getName() != null && !request.getName().isBlank()) {
sponsorBank.setName(request.getName().trim());
}
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
});
@@ -124,6 +248,11 @@ public class SponsorBankApiResource {
private String ifscCode;
private String bankCode;
private String swiftCode;
/** The GL account drafts on this bank are credited to; null until an administrator sets it. */
private Long ddPayableGlAccountId;
/** That account's code and name, so a caller can show it without fetching the chart. */
private String ddPayableGlCode;
private String ddPayableGlName;
}
@Data
@@ -135,5 +264,6 @@ public class SponsorBankApiResource {
private String ifscCode;
private String bankCode;
private String swiftCode;
private Long ddPayableGlAccountId;
}
}
@@ -279,7 +279,8 @@ public class SponsorDDApiResource {
request.getChequeNumber(),
request.getChequeBankName(),
request.getCashierDepositRequestId(),
request.getDenominationData()
request.getDenominationData(),
request.getGlAccountId()
));
}
@@ -287,7 +288,7 @@ public class SponsorDDApiResource {
@Path("inventory/{inventoryId}/issue-request-batch")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit or cheque funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.")
@Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit, cheque or GL account debit funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.")
public SponsorDDBatchIssueResult submitIssueRequestBatch(@PathParam("inventoryId") final Long inventoryId,
final IssueRequestBatchRequest request) {
final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter);
@@ -306,6 +307,7 @@ public class SponsorDDApiResource {
request.getCurrentAccountId(),
request.getChequeNumber(),
request.getChequeBankName(),
request.getGlAccountId(),
lines
));
}
@@ -314,9 +316,15 @@ public class SponsorDDApiResource {
@Path("issue-requests/batch/{batchId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting for the whole slip's total, then issues every draft in it. Approves every line together, not one at a time.")
public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId) {
return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId));
@Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting, then issues the slip's drafts. With no body (or no approvedRequestIds) the whole slip is issued. Supplying approvedRequestIds issues only those drafts, debits only for them, and rejects the rest of the slip - their DD numbers return to stock.")
public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId,
final ApproveIssueRequestBatchRequest request) {
// An empty list is read as "no selection made" rather than "approve nothing": the existing
// callers post an empty body, and a slip that issued no drafts at all is a rejection, which
// has its own endpoint.
final List<Long> requested = request == null ? null : request.getApprovedRequestIds();
final List<Long> approvedRequestIds = requested == null || requested.isEmpty() ? null : requested;
return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId, approvedRequestIds));
}
@POST
@@ -673,6 +681,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
@@ -680,11 +691,13 @@ public class SponsorDDApiResource {
private Long clientId;
private Long issuingBranchId;
private String issuedDate;
private String paymentMethod; // ACCOUNT_DEBIT or CHEQUE only — a slip does not support CASH
private String paymentMethod; // ACCOUNT_DEBIT, CHEQUE or GL — a slip does not support CASH
private Long savingsAccountId;
private Long currentAccountId;
private String chequeNumber;
private String chequeBankName;
/** The GL account a GL-funded slip is debited from; unused by every other payment method. */
private Long glAccountId;
private List<SlipLineRequest> lines;
}
@@ -702,6 +715,17 @@ public class SponsorDDApiResource {
private String rejectionReason;
}
/**
* Which drafts of a slip the customer still wants.
*
* Null or empty means all of them, so an existing caller that posts an empty body keeps issuing
* the whole slip exactly as before.
*/
@lombok.Data
public static class ApproveIssueRequestBatchRequest {
private List<Long> approvedRequestIds;
}
@lombok.Data
public static class ReprintRequestRequest {
private String reason;

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