Compare commits

..
23 Commits
Author SHA1 Message Date
nitesh c052beeacf feat(branchday): reject day close if cashiers given cash are not reconciled or settled
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 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 - MySQL / test (test-core-2) (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 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 - 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-02 12:07:24 +05:30
niteshandClaude Opus 5 942bad6c8c fix(branchday): count account closures in the day-close cash reconciliation
Day Close reported SHORT by exactly the amount a closure had paid out. The
"Cash paid out (approved withdrawals)" line only summed
m_clerk_withdrawal_request, so a closure -- which pays cash over the same
counter but lives in its own register -- was missing from the total. Expected
cash stayed high by what had actually been handed over, and a closure that
balanced perfectly read as a shortage.

Verified against live data: opening 145,304 + deposits 0 - slip withdrawals 0
- closure 12,999 = 132,305, which is exactly the counted branch total. Variance
goes from -12,999 to 0.

Sums cashier_disbursed_amount rather than closing_amount: the member's ledger
gives up the paisa-exact figure, but what physically leaves the drawer is that
figure rounded to whole rupees, and this total has to agree with a physical
count. Restricted to CLOSED rows dated on disbursed_at, since a closure takes
nothing out of the drawer until the cashier hands it over.

Note: dividend cash claims (m_share_dividend_claim_request) are also absent
from this total and will misreport the same way. Left alone deliberately --
that register splits a claim across loan/savings/cash with its own cash_status
column that nothing in the Java sets, so the right predicate cannot be
established from the code as it stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:58:12 +05:30
niteshandClaude Opus 5 5a9a103832 Merge branch 'maker-checker' into dev
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
Brings in maker-checker for GL accounts, charges, product creation (loan,
savings, current, share, FD), admin users/roles and base interest rates, plus
the UserPendingRequest domain and AppUserPreference.

Two resolutions:

changelog-tenant.xml — both branches appended <include> lines at the end of the
file, so the conflict was append-vs-append with no semantic overlap. Kept both
sets in order: dev's 0470-0499 followed by maker-checker's eight. Liquibase runs
in <include> order, not filename order, so the reused 0466-0476 numbers change
nothing (dev already carries duplicate numbers in that range).

application.properties — the branch carried another developer's local database
password, which would have stopped this backend connecting at startup. Restored
ours; the file is now byte-identical to its pre-merge state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 18:07:24 +05:30
niteshandClaude Opus 5 fd91da3d76 feat(savings,cashier): maker-checker cash closure with vouchers and rounding
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 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 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 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 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Closing a savings account paid real cash out of a teller's drawer in one
click, with no maker-checker, no cash-position tracking, and no way to hand
over a paisa balance (no paisa coins circulate, and the denomination writer
demands an exact match). This adds a Clerk -> Manager -> Cashier flow for
Cash closures, modelled on the existing Clerk Withdrawal Request.

New ClerkClosureRequestApiResource:
 - submit: Cash only, token required, guards ACTIVE status and open requests
 - approve: runs Fineract's own close command (pro-rata interest and the real
   GL withdrawal post here, atomically), reads the posted transaction back,
   rounds the payout to whole rupees and posts the difference to a new
   Round Off Account. A nil-balance closure goes straight to CLOSED rather
   than parking forever in a cashier queue with no cash to hand over.
 - disburse: cashier records the denomination breakdown against the rounded
   figure and the account closes
 - preview: dry-runs the very same interest posting close() performs, inside
   a REQUIRES_NEW transaction that is rolled back, so the figure shown to the
   clerk matches the real payout to the paisa. Reading the stored accrual
   instead gave a days-stale number, and CALCULATEINTEREST is not equivalent:
   it uses MathContext(15) and no postInterestOnDate, where close() uses
   MathContext(10) and feeds the closing date in as a posting-period
   boundary, which changes how many per-period roundings happen.

Vouchers: close() reaches handleWithdrawal directly rather than through the
withdrawal command handler, so the handler's own raiseSavingsVoucher never
ran and a closure paid cash out with nothing in the voucher register. One is
now raised at disbursement -- when the cash actually crosses the counter, in
the cashier's name -- with three lines that match the posted GL exactly:
Dr Savings Control + Dr Round Off = Cr Cash.

Cash direction: the rounding adjustment debited Cash where cash leaving the
drawer is a credit, leaving the cash GL disagreeing with what was physically
handed over. Both legs corrected.

Dates: closure_date, closedOnDate, the preview and the rounding entry now all
use the CBS business date, so preview and approval cannot land on different
days across a day roll.

Day Book: closures were absent entirely (it reads the request registers, not
m_voucher). Added as a fourth register, restricted to CLOSED rows and dated
on disbursed_at, since a closure only becomes a cash-book event at hand-over.
Its terminal status CLOSED was also missing from the totals gate, so a
completed closure moved real money out without appearing in the DR total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:20:08 +05:30
nitesh c4a99fea5d feat(savings,clerkwithdrawal): expose linked share account on savings read, add search filter to withdrawal request lists
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract 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 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 E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract 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
Surfaces shareAccountId/shareAccountNo on the savings account read API so the
UI can display and auto-select an already-linked share account; adds an
optional search query param to the clerk cash and share withdrawal request
list endpoints for the Approval Center's new search boxes.
2026-09-01 07:40:39 +05:30
niteshandClaude Sonnet 5 dc0539d53d feat(savings,shares): bidirectional savings/share account linking, optional savings link on share create
- Savings account creation validates and stores a required "shareAccountId" once Share Linking is
  enabled, checked against the client's own active share accounts (raw SQL - fineract-savings has
  no dependency on the shareaccounts domain). On save, mirrors the link back onto the share
  account's own savings_account_id, the same way that field already gets set from the share side.
- New GET /clients/{id}/activeshareaccounts - a client's active share accounts, for the savings
  creation screen's picker (kept separate from the shared Savings/FD/RD/Current template DTO).
- ShareAccountDataSerializer.validateAndCreate no longer requires savingsAccountId on create - the
  DB column and domain relationship were already nullable, only this validation blocked it.

fix(branchday): stop the denomination carry-forward preview from doubling yesterday's closing count

GET /branchday/denominations classified every detail row that wasn't literally leg='OPENING' as
"closing" - which, once CLOSING_VAULT rows existed alongside CLOSING (single-teller offices record
the same breakdown under both), silently doubled every count shown as "yesterday's closing" when
opening the next business day. Filters to leg IN ('OPENING','CLOSING') explicitly instead of an
else-catches-everything branch.

feat(accounting): seed missing Income/Expense/Transfers-Suspense/Escheat-Liability accounts

The Basoda-derived chart of accounts (0523) carried none of these - a balance sheet has no P&L
heads, and Transfers Suspense/Escheat Liability aren't balance-sheet line items either - so a
Savings Product's accounting mapping had nowhere to point those required fields. Same guard as
0523: only seeds a Basoda-derived tenant that hasn't already gotten it, never touches a tenant with
its own chart of accounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:17:54 +05:30
niteshandClaude Sonnet 5 509370eaeb fix(shares,voucher,cashreport): route redeem/withdrawal through Sundry, fix voucher cashier attribution and GL-name resolution
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 - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 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-4) (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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
- ClerkShareWithdrawalRequestApiResource: reverse native redeem GL legs and reroute through Sundry
  (Dr Share Reference / Cr Sundry, then Dr Sundry / Cr Savings Control), reusing the native
  transaction_id so both legs display as balanced; wholesale-reverse and reroute the linked savings
  credit the same way.
- ShareAccountWritePlatformServiceJpaRepositoryImpl: undoApproveShareAccount no longer re-triggers
  the native "isApplied()" phantom GL posting Fineract's accounting bridge makes on approval.
- VoucherService/ClerkWithdrawalRequestApiResource: attribute a voucher to the cashier who actually
  disbursed cash, not the submitting clerk, once disbursal completes.
- CashReportApiResource: scope the GL-entries deposit/withdrawal joins by txn_type to stop a
  hex/decimal txn_id collision from borrowing an unrelated transaction's voucher number; resolve
  GL account names (not just client names) in the "A/C NAME" column.

fix(branchday,cashreport): stop the vault position from double-counting a cashier's unsettled cash

BranchDayRegisterApiResource's day-close folded every cashier's still-held cash into the branch's
combined closing figure, which then carried forward as next day's vault opening. When that cash was
later settled back to the vault, CashReportApiResource added it a second time - once already baked
into the carried-forward opening, once as the settlement itself - manufacturing a phantom "Excess"
equal to whatever was unsettled.

Adds a vault-only OPENING_VAULT/CLOSING_VAULT leg (new VaultPositionService, shared by both
resources) alongside the existing branch-total OPENING/CLOSING legs, so the vault's own anchor never
includes cash that still physically sits with a cashier.

feat(accounting): seed a missing opening-balance contra account and a real bank's chart of accounts

- Financial Activity 300 (opening-balance transfer contra) was never mapped in any tenant's chart of
  accounts, so any opening-balance flow failed outright; backfilled it and added a migration to seed
  it for every tenant going forward.
- Seeds a brand-new, otherwise-empty tenant with a real cooperative bank's chart of accounts
  (transcribed from its balance sheet), guarded to never touch a tenant that already has its own.
- Removes 0451's hardcoded demo branch offices, which were seeding into every new tenant regardless
  of institution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:06:57 +05:30
niteshandClaude Sonnet 5 1130a482a0 feat(shares): centralized Additional Shares approval queue, remove unused Share Savings Debit
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 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 - 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 Docker Builds / build (true, docker-compose.yml, mariadb) (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 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 - 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
- Add ShareAdditionRequestsApiResource (/v1/shareadditionrequests): lists every pending
  additional-share tranche across all accounts, with a per-tranche detail view carrying the
  route-specific proof of funding a list row can't show (which voucher(s) fund a cash tranche, or
  which savings account a savings tranche already debited). Approval/rejection still go through
  Fineract's own approveadditionalshares/rejectadditionalshares commands unchanged -- this is a read
  side only, so the existing settle()/unwind() hooks fire from exactly one code path.
- Remove ShareSavingsDebitRequestApiResource and its m_share_savings_debit_request table (migration
  0520): confirmed zero rows on both tenants since it was built, and superseded by Additional Shares'
  own savings-funding route, which values a tranche on its own rather than by summing the account's
  entire holding history.
- Update comments in ShareAccountWritePlatformServiceJpaRepositoryImpl, ShareAccountDataSerializer,
  AdditionalShareTrancheFundingService and AdditionalSharesFundingApiResource that referenced the
  removed class/endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:37:33 +05:30
niteshandClaude Sonnet 5 8ecab08d57 fix(shares,savings): remove product-config voucher auto-resolve, fix charge/currency edit bugs, gate withdrawal on settlement
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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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
- ShareAdvanceCollectionApiResource: drop the hardcoded/auto-resolved Share Suspense GL lookup for
  by-voucher search; GL account and voucher are now both required, manually-selected inputs. apply()
  now accepts a separately-verified entry-fee receipt (chargeCollectionId) so a fee collected into a
  different GL than the share value is never swept into the Sundry leg or double-posted to income.
- ClerkShareWithdrawalRequestApiResource: hard-block approval when collection_applied is false, so
  shares nobody has actually paid for can't be redeemed off an Active-but-unsettled account.
- ProductPendingEditFieldMapper / ShareProductDataSerializer: fix maker-checker edits on share
  products silently dropping charges (chargesSelected vs charges field-name mismatch) and NPEing on
  approval when the replay JSON carries no currencyCode.
- SavingsAccount: initialize the clearing-hold field to zero so new savings submissions don't violate
  the NOT NULL constraint on total_savings_amount_on_hold_clearing.
- CashReportApiResource: fix voucher number resolution for GL-type ledger entries.
- application.properties: revert local tenant DB password default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:02:50 +05:30
keshav 29677e610c done
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 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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-30 11:10:42 +05:30
niteshandClaude Sonnet 5 6ffb0dd9c0 fix(savings,cashier): auto-post pro-rata interest on closure, split Cash/Clearing hold, fix WITHDRAWN-status blind spots
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 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 - 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-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 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 - MySQL / test (test-core-2) (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-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 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Closing a savings account used to only ever validate that interest had
already been posted on the closing date (opt-in, off by default) -- with
nothing posted beforehand the partial period's interest was simply never
paid. It now always calculates and posts it before the account closes,
reusing the same machinery the manual "post interest as of date" action
already relied on.

Savings accounts now track two separate hold totals instead of one: a
manual/staff-placed Cash Hold (the existing lien mechanism, unchanged for
every caller that doesn't ask for the new category) and a Clearing Hold for
money still moving through the clearing process -- a Cheque/DD cashier
deposit now automatically places one for the full amount at approval time,
and CTS inward-clearing holds are now bucketed here too, since both are
genuinely about the clearing pipeline rather than a lien someone chose to
place. A new self-contained endpoint releases due Clearing Holds (wire it
into an external scheduler); the existing manual release action now credits
back whichever bucket the original hold actually debited. Approving a
Cheque/DD deposit dispatches this automatically as the approving manager,
so the checker roles that call deposit-approval needed the underlying
HOLDAMOUNT/RELEASEAMOUNT permissions granted -- without it every such
approval would now fail outright; a stray column-name bug in the payment-
type lookup that surfaced this in testing is fixed too, guarded going
forward by a try/catch so a problem in this best-effort step can never
sink the deposit approval it rides along with.

A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED --
deposits and disbursements use APPROVED, withdrawal is the one register
that doesn't. Three places were still filtering on APPROVED alone and
silently excluding every completed withdrawal: the Day Book's CR/DR
totals, and the branch day register's own "cash the branch should be
holding" formula (which overstated the expected figure by exactly what
had been paid out, manufacturing a phantom cash-short every time a
withdrawal happened that day).

The GL Entries report showed a bare internal transaction id under
"Voucher No" instead of the real formatted voucher stamped on the
underlying deposit/withdrawal request; it now resolves and shows that
instead. Denomination rows saved at deposit/withdrawal submission never
recorded which payment type was used, always leaving "Payment Type" blank
regardless of what the cashier actually collected -- both insert paths
now carry it through, and existing rows are backfilled.

FacilityChargeSeederService no longer hardcodes an income GL account onto
the facility charges (ATM, UPI, Cheque Book, NEFT, ...) it seeds. Fineract's
accounting engine uses a charge's own GL account ahead of its savings
product's own "Income From Fees" mapping whenever one is set, so seeding
all sixteen onto the same shared account (picked essentially by accident)
silently overrode every product's own accounting configuration with it.
Leaving it unset lets each product's own mapping decide where this income
actually posts, which is the point of that mapping existing at all.

Institution/Branch Profile's READ permission was scoped to manager/admin
roles only, since it started as an org-setup-only screen -- now that the
Passbook and Statement screens read it for their letterhead (real bank
name, license number, branch IFSC/MICR) instead of a hardcoded name, and
both are reachable by a cashier/clerk/teller, the read-only permission is
extended to those roles too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 10:37:53 +05:30
niteshandClaude Sonnet 5 8d224a22b4 fix(branchday): correct timestamp serialization, require a reason for an opening cash mismatch
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 - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 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 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
Opened-by/closed-by times on the day register were rendering blank: the
JDBC Timestamp's own toString() separates date and time with a space, not
the ISO-8601 'T' every frontend consumer parses on. str() now formats a
Timestamp explicitly instead of falling through to the generic toString().

Opening a business day with a note/coin count or lump sum that did not
match the branch's own closing figure from the day before was previously
accepted with no record of why -- every downstream figure (vault position,
EOD summary, the next day's own carry-forward) would then be built on an
unexplained short or excess baseline with nothing to explain it later. The
open endpoint now requires an openingDifferenceReason whenever the caller
supplies a figure that differs from the prior close, enforced server-side
so it can't be bypassed by calling the API directly; a day with no prior
close, or one left to the automatic carry-forward untouched, is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 11:01:30 +05:30
niteshandClaude Sonnet 5 f746a1fb2c feat(cashier,accounting): denomination-aware vault controls, Bank/Branch Master, Fund Transfer request type
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract 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 - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Closes the gap where a vault could hand out more cash (in total or in a
specific note/coin) than it actually held: CashTransferApiResource now
validates against the branch day's opening denomination breakdown plus
today's settlements/transfers, and blocks entirely when no business day is
open. BranchDayRegisterApiResource gains a real denomination breakdown per
open/close leg (migration 0510), carried forward automatically day to day,
and its book-cash formula no longer double-counts vault-to-cashier transfers
as new cash in. CashReportApiResource adds a vault-position report (the
teller/vault analogue of cashier-position) and fixes an off-by-one date
comparison that excluded same-day transfers from a vault's anchor position.

Institution/Branch Master: new InstitutionProfileApiResource and
BranchProfileApiResource (migrations 0511-0513) give the bank a real
"Bank Master" profile (identity, regulatory details, address, SWIFT/BIC) and
each office its own branch-level fields (code, IFSC/MICR, working hours,
geo-location) as side tables on m_office rather than touching the stock
Fineract office entity. ClearingGlConfigApiResource exposes the previously
config-only-via-SQL m_clearing_gl_config mapping (CTS/NACH/RTGS suspense
accounts) as a proper admin API.

ClerkDisbursementRequestApiResource now accepts a FUND_TRANSFER request type
alongside DISBURSEMENT/COLLECTION/BANK_TRANSFER, and migration 0514 grants
the teller-facing roles READ_CLIENTNOMINEE, which they were missing entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 14:36:59 +05:30
niteshandClaude Sonnet 5 1bc9d0901d feat(savings): facility charge mapping admin API, enforce mapped-charge validation, INR-only currency, GL chart backfill
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 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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 - 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract 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
Adds the admin read/write API for m_facility_charge_mapping (list, template,
update) so facilities can be pointed at a real charge instead of relying on
the seeder's implicit auto-link, and makes enable-on-account paths (activation
and edit-approval, both savings and current accounts) throw instead of
silently skipping the charge when a facility isn't mapped or is inactive.
Migrations 0497-0509 back this up: reset all facility mappings to
unmapped/inactive by default, backfill lost roles/permissions/charges, enforce
INR as the only organisation currency, backfill the full real-world chart of
accounts (44 core + 60 priority GL heads) with standardized gl_codes, and
retire the unused system-defined payment types (Share Redemption Transfer and
friends) in favor of Cash/Transfer only, matching the teller dashboard's
simplified payment-type flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:41:42 +05:30
niteshandClaude Sonnet 5 58187c23d0 fix(migrations): remove fake test-data seeds, backfill lost permissions, merge redundant roles
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 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-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
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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-3) (push) Canceled after 0s
Migration-hygiene fixes found while auditing whether a fresh database would
end up with the same schema/permissions/roles as this dev database.

1. Remove fake test-data seed migrations

   0449_seed_aml_test_alerts.xml and 0459_seed_aml_alerts_awaiting_review.xml
   unconditionally inserted 3 fictional clients (CLIENT_RAJESH/SONIA/VIKRAM)
   and 5 fake AML alerts against them on every environment, including
   production if this migration path ever ran there. 0459's own comment
   confirms it was written purely to exercise the AML review UI. Deleted
   both files and their <include> entries; cleaned the same fake rows out
   of this dev database (3 clients, 5 alerts, plus the now-orphaned
   databasechangelog bookkeeping rows for the two deleted files).

2. Backfill 3 permissions lost to a migration-number collision

   Auditing all 2712 changesets applied to this dev database against the
   current codebase found 18 changeset filenames with no matching file
   anywhere in git; changelog-tenant.xml itself already documents the root
   cause (multiple branches independently reusing the same numeric prefix
   for unrelated migrations, e.g. 0456-0462 twice). Cross-checking each
   affected table/permission against actual Java usage (@CommandType
   handlers, domain/API classes, and both permission-insert styles used in
   this codebase -- Liquibase <insert> columns and raw <sql> literals)
   showed 15 of the 18 are genuinely dead (reconciliation-batch/settlement-
   cycle prototype tables, fixed asset/inventory tracking) with zero
   current code referencing them, so nothing to backfill there. The
   remaining 3 -- MENU_RECONCILIATION, READ_RECONCILIATION,
   SIDEBAR_RECONCILIATION -- gate a live module (CashReconciliation /
   ClearingReconciliation have real API resources and domain classes) but
   were never seeded by any migration; without them a fresh database can
   build and expose the reconciliation screens with no way to grant a role
   access to them. Added 0491_add_reconciliation_menu_permissions.xml.

3. Merge two pairs of redundant roles

   - "Bank Cashier" (4 fund-transfer permissions, 0 of which overlapped
     "CASHIER", 0 users assigned) folded into CASHIER.
   - "AML Officer" (2 permissions, both already present on "Principal
     Officer", 0 users assigned) folded into Principal Officer.
   Both migrations copy any missing permissions and move any assigned
   users onto the surviving role before deleting the redundant one, so
   they're safe to run against an environment that isn't empty the way
   this dev database is. Applied the same merge directly to this dev
   database's m_role / m_role_permission / m_appuser_role rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:27:42 +05:30
keshav 179fe3a6ce implement share account actions, view components, and pending checker tasks
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-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-08-27 10:27:58 +05:30
niteshandClaude Sonnet 5 72221f704d feat(deposits,clients): FD Special auto-configuration, interest schedule projection, and minor-age backend mirror
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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 - MySQL / test (test-core-1) (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
Three related pieces of backend support for this session's FD Special
(monthly interest payout) work, plus one more instance of the same
transaction-ordering bug fixed elsewhere this session.

1. FD Special account creation/modification auto-configuration

   FD Special (fdType=SPECIAL) is a non-cumulative product: interest must
   post monthly and leave for a linked savings account, and maturity must
   auto-transfer the principal out -- regardless of whatever compounding/
   posting period or maturity instruction the request otherwise carries.
   Previously none of this was enforced, so a SPECIAL account could be
   created with e.g. quarterly compounding, or a later "modify application"
   request could silently unset transferInterestToLinkedAccount / the
   maturity instruction / the linked account by mere omission of those
   fields from the payload, while the account kept displaying as "FD
   Special".

   - DepositAccountAssembler: on create, forces interestCompoundingPeriodType
     and interestPostingPeriodType to MONTHLY, transferInterestToSavings to
     true, transferToSavingsId to the (now required) linked savings account,
     and accountOnClosureType to TRANSFER_TO_SAVINGS, whenever fdType is
     SPECIAL. Throws a clear domain error if no linked account was selected.
   - DepositAccountTermAndPreClosure: new forceMonthlyPayoutToLinkedSavings(),
     re-applying those same three standing-instruction fields.
   - DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl: calls
     the equivalent re-enforcement on every modify-application request for
     a SPECIAL account, after the linked-account resolution in that same
     method has settled what account (if any) ends up linked.

2. FD Special projected interest schedule (engine + API)

   Staff/customers previously only saw a flat rate/12 approximation of
   monthly interest. Added a real, engine-computed month-by-month schedule:
   - DepositAccountWritePlatformService(Impl): new
     retrieveProjectedInterestSchedule(), running the same PostingPeriod
     engine the real maturity calculation uses (FixedDepositAccount#
     projectInterestSchedule), side-effect-free (@Transactional(readOnly)),
     clipping the last period's displayed end-date to maturityDate-1 (the
     engine otherwise rounds it out to the next calendar boundary even when
     maturity falls partway through), and flagging each period "paid" by
     matching real posted-interest transaction dates.
   - FixedDepositAccountsApiResource: computes and attaches the schedule,
     plus server-computed totalProjectedInterest/totalValueAtMaturity, to
     the FD template response for SPECIAL accounts only.
   - FixedDepositAccountData / DepositsApiConstants: carrier fields and
     response-parameter whitelist entries for the three new fields.

3. Minor-age threshold: backend now matches the frontend's shared constant

   Companion to this session's frontend minor-age-threshold change
   (18 -> 10, see client-age.util.ts). ClientKycApiResource's minor
   detection (which gates the required-KYC-document list, including
   whether PAN is mandatory) was still hardcoded to age < 18. Added
   ClientApiConstants.MINOR_AGE_THRESHOLD (10) and SENIOR_CITIZEN_AGE_
   THRESHOLD (60) as the backend's own named constants, and switched the
   KYC endpoint to use MINOR_AGE_THRESHOLD, so the two sides can't silently
   disagree on who counts as a minor.

4. SavingsAccountTransactionComparator: same-date tie-break fixes another
   instance of this session's core reversal bug

   For a Special FD's interest-transfer sweep, the withdrawal shares the
   exact transaction_date of the interest posting it pays out. When that
   posting is reversed and recreated, the new posting transaction gets a
   higher id than the already-existing withdrawal; the comparator's id-order
   tiebreaker then sorted the withdrawal before the new posting, which
   understated the running balance shown between them (e.g. showing
   Rs.99,901.39 instead of Rs.100,000.00 immediately after the pair). Added
   an explicit tiebreaker so a same-date, same-createdDate interest posting
   always sorts before a withdrawal/debit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 19:02:17 +05:30
niteshandClaude Sonnet 5 a42a874811 fix(deposits): stop FD Special interest postings from self-correcting after settlement
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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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
FD Special (monthly interest payout) accounts post interest each month and
immediately sweep it to a linked savings account. Two independent interest
recalculation paths were treating that sweep withdrawal as ordinary account
activity and silently reversing + reposting already-settled periods at a
slightly different (rounding-drifted) amount — after the original, correct
amount had already been transferred out. This left the FD short by the
difference every time it happened.

Root causes (two separate engines, same underlying bug pattern):

1. SavingsAccountDomainServiceJpa#handleWithdrawal re-triggers a full
   postInterest()/calculateInterestUsing() after every withdrawal, including
   the sweep withdrawal itself. At that exact moment the withdrawal's own
   m_account_transfer_transaction linking row does not exist yet, so the
   balance-exclusion logic can't recognise it as a transfer leg — it gets
   treated as a real balance-reducing transaction and corrupts that period's
   (and any later period's) interest calculation.

2. SavingsAccountInterestPostingServiceImpl (the "Post Interest For Savings"
   batch job) has its own, separate reverse-and-repost correction loop with
   the same lack of a "was this period already settled" guard.

Fixes, both scoped strictly to FD Special (transfer_interest_to_linked_account
= true) so plain savings and recurring deposit behaviour is provably
unchanged (the new hooks all default to the prior behaviour unless
overridden):

- SavingsAccount: new isInterestPostingCorrectionAllowed(...) extension
  point guarding the postInterest() correction loop; new
  isPostWithdrawalInterestRecalculationRequired(...) hook checked by
  handleWithdrawal before it re-recalculates; new
  refreshBalancesAndSummaryAfterTransfer(...) — the running-balance/summary
  bookkeeping postInterest() always performs, without re-running the
  posting/correction loop, so skipping the full recalculation doesn't leave
  the withdrawal's own running balance and the account summary stale.
- FixedDepositAccount: overrides both hooks using the existing
  interestAlreadyPaidOut(...) date-matched sweep-withdrawal check (already
  used by postMaturityInterest) so an already-swept period is never
  recalculated again, from either the withdrawal path or a later job run.
- SavingsAccountInterestPostingServiceImpl: mirrors the same guard
  (isInterestAlreadySweptOut) in its own correction loop for the batch job.

Unrelated correctness fixes carried in the same files, applicable to any
account type (not FD-Special-specific), found while investigating the above:

- SavingsAccountWritePlatformServiceJpaRepositoryImpl: interest-payable GL
  entries were reading a financial_account_type that never exists in
  acc_product_mapping, always resolving to account id 0. Now reads the
  correct getGlAccountIdForInterestOnSavings().
- DepositAccountInterestRateChart#getApplicableInterestRate: missing break
  in the slab-matching loop meant HashSet's non-deterministic iteration
  order could pick an arbitrary matching slab instead of the intended one.
- SavingsAccountReadPlatformServiceImpl / SavingsAccountData: thread the
  real transfer_interest_to_linked_account flag into the batch interest
  engine's per-account data so it can correctly disable interest
  compounding for FD Special (previously hard-coded false, causing the
  batch job to compound interest that should reset to zero each period).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 18:53:12 +05:30
keshav 1a2a945464 Merge branch 'origin/dev' into dev
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 Documentation build / build (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-1) (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 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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-08-25 11:32:55 +05:30
keshav 73ec85cfd6 feat: implement account register, branch day, voucher verification, and share dividend backend APIs 2026-08-25 11:31:49 +05:30
niteshandClaude Sonnet 5 46f5a1babd feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract 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 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
WHAT THIS CHANGES
==================
Three related FD (Fixed Deposit) features, built incrementally in one session:

1. IN-PLACE RENEWAL -- when an FD matures with "Re-Invest Principal and
   Interest", the SAME m_savings_account row is now reused across every
   renewal (account number never changes) instead of closing the account
   and opening a new one. FixedDepositAccount.renewInPlace() does this by
   setting start_interest_calculation_date as a pivot: interest for terms
   before the pivot is never recalculated, only the new term's window is
   computed from there. A new one-to-many audit table m_fd_renewal_history
   records one row per term (renewal_no 0,1,2...) with that term's own
   principal, rate, dates, interest earned, maturity, and status. A
   companion table m_fd_renewal_chart_slab_history archives each closing
   term's full rate-by-tenure chart, captured just before renewInPlace
   overwrites it.

2. SENIOR-CITIZEN RATE -- the CBS Base Interest Rate master
   (m_cbs_base_interest_rate_slab_history) was the only place that ever
   stored a senior-citizen premium. It is now also persisted on
   m_savings_account_interest_rate_slab (new senior_citizen_rate column,
   nullable, added only to this account-level table -- NOT to the shared
   InterestRateChartSlabFields embeddable, so the product-level table
   m_interest_rate_slab is untouched). Applied both at account creation
   (DepositAccountAssembler now prices a new FD off the active CBS base
   version when one exists for that FD type, falling back to the
   product's own chart otherwise) and at renewal (renewInPlace refreshes
   the chart from the same base version).

3. PAYMENT ACTION -- a new manual command, POST
   /fixeddepositaccounts/{id}?command=payment, available whether the FD
   is ACTIVE or MATURED, unlike Close (Matured-only) and Premature Close
   (Active-only, no reinvest). Offers: Transfer to Savings, Re-Invest
   Principal and Interest (via renewInPlace), Re-Invest Principal Only
   (legacy close-and-reopen, now also transfers the leftover interest to
   a chosen savings account instead of stranding it), and a new closure
   type PARTIAL_REINVEST (500) -- part of the matured balance renews in
   place on the same account, the remainder transfers to a savings
   account picked at request time. Withdraw Deposit is deliberately
   excluded from this command. New permission PAYMENT_FIXEDDEPOSITACCOUNT
   plus its CHECKER twin were added in migration 0478.

KEY FILES
=========
- DepositAccountDomainServiceJpa.java: handleFDAccountPayment (new),
  renewInPlace call sites, recordInPlaceRenewal, senior-citizen rate
  matching with unit conversion across days/weeks and months/years
  families.
- FixedDepositAccount.java: renewInPlace, validateRenewalDate,
  isLockedOnDate, fdAccountNumber/renewalCycleNo chain tracking.
- SavingsAccount.java (SHARED with Savings and RD): added
  findLastTransactionBefore -- the interest-posting opening balance for a
  pivot-dated account now seeds from the last transaction strictly before
  the pivot, not the one on it. This fixes a bug where a renewed term
  could accrue zero interest, or double-subtract a same-day withdrawal,
  depending on transaction list order. Also widened
  calculateDateAccountIsLockedUntil to protected so renewInPlace can
  refresh lock-in on each renewed term.
- DepositAccountTransactionDataValidator.java: new validatePayment.
  validateClosing now explicitly rejects PARTIAL_REINVEST (500) so it can
  never reach the Close or Premature-Close handlers, which have no branch
  for it and would otherwise pay the whole balance out in cash. This
  applies to both FD and RD, since they share validateClosing.
- DepositAccountReadPlatformServiceImpl.java: retrieveOneWithClosureTemplate
  (Close and Premature Close, excludes PARTIAL_REINVEST) split from the
  new retrieveOneWithPaymentTemplate (Payment, includes it), sharing one
  private method with a flag. The renewal-history fallback query, for
  chains that predate m_fd_renewal_history, was rewritten to emit the
  exact same column shape as the primary query.
- New: FixedDepositRenewalHistory plus its repository,
  FixedDepositRenewalChartSlabHistory plus its repository,
  PaymentFixedDepositAccountCommandHandler.
- Migrations 0471 through 0478 under tenant/parts, each registered once
  in changelog-tenant.xml.

RD AND SAVINGS IMPACT -- READ THIS IF SOMETHING BREAKS THERE
============================================================
RD and plain Savings share several of the touched files. Verified during
this work:
  - handleRDAccountClosure and RecurringDepositAccount.java were not
    modified.
  - PARTIAL_REINVEST (500) is now rejected by validateClosing for both FD
    and RD close and prematureClose commands, and excluded from their
    template option lists. This was a real regression caught mid-session:
    the enum value briefly leaked into RD's Close dropdown as a working
    looking option that actually paid the whole RD balance out in cash.
    It was found and fixed before this commit.
  - The SavingsAccount.calculateInterestUsing opening-balance change only
    affects accounts where start_interest_calculation_date differs from
    activatedon_date. On the dev DB this was exactly two accounts, both
    FD test accounts created this session. Before deploying, run:
      SELECT deposit_type_enum, COUNT(*) FROM m_savings_account
      WHERE start_interest_calculation_date IS NOT NULL
        AND start_interest_calculation_date <> activatedon_date
      GROUP BY deposit_type_enum;
    Any non-FD rows (100 or 300) there mean a Savings or RD account will
    get a different, corrected interest amount than before. This is
    expected to be rare or zero, but confirm before deploy.
  - DepositAccountAssembler's creation-time base-rate pricing is guarded
    by depositAccountType.isFixedDeposit, so RD account creation is
    unaffected.
  - is_active on InterestRateChart (migration 0474) is read only by
    FixedDepositProduct.activeChart, which only FD's renewInPlace and
    reInvest call. RD's applicableChart path is untouched. Migration 0474
    also retires (is_active = false) any chart whose end_date is already
    past. On the dev DB this retired zero rows; check on the target DB
    before deploy, since there is currently no API to reactivate a
    retired chart.

KNOWN CAVEATS -- NOT YET DONE, FLAGGING RATHER THAN HIDING
============================================================
  - Migrations 0471 through 0477, the DDL ones, have never actually
    executed via Liquibase. They were mirrored by hand directly into the
    dev DB (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS) so
    testing could proceed without a full rebuild each time. Liquibase
    will mark them MARK_RAN via preConditions on the dev DB's next real
    run, but their first genuine execution will be on whatever
    environment does not already have these columns and tables, such as
    a fresh QA or production database. Test a migration-from-scratch run
    before relying on this in production.
  - This code has not been compiled. Every new or changed method
    signature, import, and call site was manually cross-checked with
    grep against real declarations, but that is not a substitute for
    gradle actually compiling it. Run a full build before deploying and
    treat any compiler error as expected until confirmed clean.
  - DepositAccountAssembler pricing every new FD off the active CBS base
    rate master, instead of the product's own chart, whenever one exists
    is a real behaviour change affecting all new FD accounts of a type
    that has a base-rate version, not just senior-citizen ones. Flagging
    this for explicit product sign-off, not assuming it in this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:44:11 +05:30
niteshandClaude Opus 5 20fe11e60d fix(deposits): correct FD reinvestment on maturity and unblock product edit submission
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract 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 E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract 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-4) (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 - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract 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
Reinvestment on maturity was creating the new deposit with a zero balance, because it
sourced the amount from activateWithBalance() -- a guard that deliberately returns zero to
stop a manually opened FD from auto-crediting itself. It now carries over what reInvest()
actually put on the new account. Both legs of the renewal are also posted as an internal
transfer, so the funds move through the transfer-suspense GL instead of appearing to leave
and re-enter the cash vault. Reinvested accounts no longer fall back to a 0% rate when no
chart slab covers the tenure, and a senior-citizen depositor keeps their premium rate on the
renewed term.

Adds a reinvestment trail: the new account records which account it came from, and the
account view resolves both directions of the link so either side can be traced.

Also fixes product edit submission, which failed outright for any product carrying
payment-channel GL mappings: the pending-edit mapper read "glAccountId" while the payload
carries "fundSourceAccountId" (and "incomeAccountId" for fee/penalty arrays), leaving
gl_account_id null and tripping the satellite table's NOT NULL constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:57:02 +05:30
niteshandClaude Sonnet 5 eb1aa767f7 fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
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 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 10 of 10) (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 - 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 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 - 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
Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:04:23 +05:30
224 changed files with 23785 additions and 1721 deletions
@@ -437,6 +437,7 @@ public final class AccountingConstants {
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
@@ -2905,6 +2905,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
this.actionName = "PAYMENT";
this.entityName = "FIXEDDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
return this;
}
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
this.actionName = "CALCULATEINTEREST";
this.entityName = "FIXEDDEPOSITACCOUNT";
@@ -3111,6 +3120,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
this.actionName = "UPDATE";
this.entityName = "FACILITYCHARGEMAPPING";
this.entityId = mappingId;
this.href = "/facilitychargemappings/" + mappingId;
return this;
}
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
this.actionName = "DELETE";
this.entityName = "FINANCIALACTIVITYACCOUNT";
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -39,6 +39,7 @@ public enum JobName {
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
// differentiate deposit accounts Savings, FD and RD accounts
private final EnumOptionData depositType;
/**
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
* for an FD that has never been renewed, and is null for non-deposit accounts.
*/
private final String fdAccountNumber;
/**
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -162,6 +162,15 @@ public class ClientApiConstants {
public static final String ageDaysParamName = "ageDays";
public static final String nationalityIdParamName = "nationalityId";
public static final String isMinorParamName = "isMinor";
/**
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
* list (with mandatory PAN) for a client the UI classifies as a minor.
*/
public static final int MINOR_AGE_THRESHOLD = 10;
/** At or above this age a client qualifies for senior-citizen deposit rates. */
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
public static final String guardianClientIdParamName = "guardianClientId";
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
public static final String guardianRelationParamName = "guardianRelation";
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
private final Integer value;
private final String code;
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
case 200 -> TRANSFER_TO_SAVINGS;
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
default -> INVALID;
};
}
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
return this.equals(INVALID);
}
public boolean isPartialReinvest() {
return this.equals(PARTIAL_REINVEST);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
fixedDepositResponseData.add("projectedInterestSchedule");
fixedDepositResponseData.add("totalProjectedInterest");
fixedDepositResponseData.add("totalValueAtMaturity");
return fixedDepositResponseData;
}
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
public static final String statusParamName = "status";
public static final String subStatusParamName = "subStatus";
public static final String reasonForBlockParamName = "reasonForBlock";
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
public static final String holdCategoryParamName = "holdCategory";
public static final String clientIdParamName = "clientId";
public static final String isRetailAccountParamName = "isRetailAccount";
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
public static final String endDateParamName = "endDate";
public static final String introducerRequiredParamName = "introducerRequired";
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
public static final String shareAccountIdParamName = "shareAccountId";
public static final String nominationFacilityParamName = "nominationFacility";
public static final String dormancyAlertParamName = "dormancyAlert";
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
private LocalDate endDate;
private Boolean introducerRequired;
private Boolean shareLinkingEnabled;
private Long shareAccountId;
private String shareAccountNo;
private Boolean nominationFacility;
private Boolean dormancyAlert;
private Boolean isDormancyTrackingActiveFromProduct;
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
private BigDecimal interestPosting;
private BigDecimal overdraftPosting;
/**
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
* Such interest never stays in the account, so it must never compound -- every period earns on the
* principal alone. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean transferInterestToLinkedAccount;
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
}
public boolean isTransferInterestToLinkedAccount() {
return this.transferInterestToLinkedAccount;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
break;
case PARTIAL_REINVEST:
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
}
return optionData;
}
@@ -0,0 +1,185 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
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;
/**
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
* how much is still unclaimed, and how much has come back to profit.
*
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
* money moved; the locks are what it moved for.
*
* The columns mirror the money's actual path, which begins and ends in the same place:
*
* <pre>
* declare Dr Bank Profit Cr Dividend Payable
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
* </pre>
*
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
* is what is still sitting unclaimed and still within its three-year window.
*/
@Path("/v1/accounting/dividend-yearwise-report")
@Component
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
@RequiredArgsConstructor
public class DividendYearwiseReportApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String report() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
// through the per-member detail rather than counted off the declaration, because a lock is only
// created for a member who actually had something to collect.
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
// Summing the declaration amount across a join to its locks would multiply it by the number
// of members it was split between.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
WITH fy AS (
SELECT d.id,
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
d.amount
FROM m_share_product_dividend_pay_out d
),
declared AS (
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
FROM fy GROUP BY fy_start
),
outcomes AS (
SELECT fy.fy_start,
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
FROM fy
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
GROUP BY fy.fy_start
)
SELECT d.fy_start, d.declaration_count, d.declared_amount,
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
o.earliest_release_due
FROM declared d
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
ORDER BY d.fy_start DESC
""");
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
// member, so no lock was ever created for it. Pulled separately and matched by year.
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
THEN EXTRACT(YEAR FROM period_end_date)
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
COALESCE(SUM(residue_amount), 0) AS residue
FROM m_share_dividend_rounding_residue
GROUP BY fy_start
""")) {
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
}
final List<Map<String, Object>> years = new ArrayList<>();
BigDecimal totalDeclared = BigDecimal.ZERO;
BigDecimal totalClaimed = BigDecimal.ZERO;
BigDecimal totalUnclaimed = BigDecimal.ZERO;
BigDecimal totalForfeited = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("financialYear", financialYear);
entry.put("declarationCount", row.get("declaration_count"));
entry.put("declaredAmount", declared);
entry.put("claimedAmount", claimed);
entry.put("claimedCount", row.get("claimed_count"));
entry.put("unclaimedAmount", unclaimed);
entry.put("unclaimedCount", row.get("unclaimed_count"));
entry.put("forfeitedAmount", forfeited);
entry.put("forfeitedCount", row.get("forfeited_count"));
entry.put("roundingResidueAmount", residue);
// What has already gone back to profit for this year: forfeited entitlements plus the
// rounding residue swept at declaration time.
entry.put("returnedToProfitAmount", forfeited.add(residue));
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
// amount starts returning to profit.
entry.put("earliestReleaseDueDate",
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
years.add(entry);
totalDeclared = totalDeclared.add(declared);
totalClaimed = totalClaimed.add(claimed);
totalUnclaimed = totalUnclaimed.add(unclaimed);
totalForfeited = totalForfeited.add(forfeited);
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("years", years);
response.put("totalDeclared", totalDeclared);
response.put("totalClaimed", totalClaimed);
response.put("totalUnclaimed", totalUnclaimed);
response.put("totalForfeited", totalForfeited);
return gson.toJson(response);
}
private String financialYearLabel(final int startYear) {
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
}
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -24,6 +24,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -104,6 +105,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.NonTransientDataAccessException;
@@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
private final LoanTransactionRepository loanTransactionRepository;
private final VoucherService voucherService;
@Transactional
@Override
@@ -232,14 +236,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
}
// Raise the voucher from the entries that were actually written, rather than from the
// command: an accounting rule can supply a side the request never named, so reading
// back by transaction id is the only view that matches what the ledger now holds.
final String voucherNo = raiseJournalVoucher(transactionId);
if (voucherNo != null) {
changes.put("voucherNo", voucherNo);
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
.withTransactionId(transactionId).build();
.withTransactionId(transactionId).with(changes).build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
final Throwable throwable = dve.getMostSpecificCause();
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
}
}
/**
* Records a manual journal in the voucher register and returns its number.
*
* The sides are read back from the entries just written rather than taken from the request,
* because an accounting rule can contribute a debit or credit the caller never named; only the
* persisted rows show what the ledger actually holds.
*
* A failure here does not fail the journal. The entries are already posted and correct, and
* refusing them because the register could not be written would reject a sound accounting
* transaction over its paperwork. The problem is logged and the journal stands without a
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
*/
private String raiseJournalVoucher(final String transactionId) {
// A register that posts its ledger movement through this command raises its own voucher,
// naming the member and the instrument. Raising a journal one beside it would put the same
// money in the register twice.
if (!this.voucherService.shouldRaiseVoucher()) {
return null;
}
try {
final List<JournalEntry> entries = this.glJournalEntryRepository
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
if (CollectionUtils.isEmpty(entries)) {
return null;
}
final List<VoucherLine> lines = new ArrayList<>();
for (final JournalEntry entry : entries) {
final GLAccount account = entry.getGlAccount();
final Long accountId = account == null ? null : account.getId();
final String name = account == null ? null : account.getName();
final String code = account == null ? null : account.getGlCode();
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
}
// Debits first, so the voucher prints the way a slip is read.
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
final AppUser currentUser = this.context.authenticatedUser();
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
return null;
}
}
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
/**
@@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
ExternalAssetOwnerRepository externalAssetOwnerRepository,
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
LoanTransactionRepository loanTransactionRepository) {
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
voucherService);
}
}
@@ -0,0 +1,264 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* How much profit is available to fund a share dividend, for a given period.
*
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
* for another share product over the same period, since they all draw on the same balance.
*
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
* screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a
* second time would hold back money that has already left the account.
*
* Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
*/
@Path("/v1/accounting/distributable-profit")
@Component
@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.")
@RequiredArgsConstructor
public class DistributableProfitApiResource {
/**
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
* IncomeProfitTransferService.PROFIT_GL_CODE.
*
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
* offer profit already distributed once for distribution again.
*/
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("excludeProductId") final Long excludeProductId) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
if (to.before(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
// A dividend is normally declared for a whole, closed financial year, and then the audited
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
// only be an interim declaration, which necessarily runs against the still-open P&L.
final String financialYear = financialYearOf(from.toLocalDate());
final LocalDate[] fyRange = financialYearRange(financialYear);
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
final boolean yearClosed = closedProfit != null;
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
// the profit-distribution screen before any dividend is declared, so the profit this reads
// has already had them taken out — subtracting them again would hold back money that is no
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
// than a negative figure a caller could read as a credit.
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO;
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
BigDecimal alreadyDeclared = BigDecimal.ZERO;
for (final Map<String, Object> declaration : existingDeclarations) {
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
}
// An over-declaration that already happened (or appropriation rates raised after the fact)
// must not present as negative headroom, which a caller could read as a credit.
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("fromDate", fromDate);
response.put("toDate", toDate);
response.put("financialYear", financialYear);
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
response.put("yearClosed", yearClosed);
// Names the figure so the screen can say where it came from — the closed books, or a P&L
// that is still moving — rather than showing a bare number the officer has to interpret.
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
response.put("netProfit", netProfit);
response.put("distributableProfit", distributableProfit);
response.put("alreadyDeclared", alreadyDeclared);
response.put("availableForDeclaration", availableForDeclaration);
response.put("maximumRatePercentage", maximumRatePercentage);
response.put("existingDeclarations", existingDeclarations);
return gson.toJson(response);
}
/**
* The year's profit as the books actually record it, once the year has been closed.
*
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
*
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
* figure that is still moving.
*/
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
final Integer closedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
if (closedCount == null || closedCount == 0) {
return null;
}
final LocalDate[] range = financialYearRange(financialYear);
final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
private LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (final RuntimeException e) {
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
}
}
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
private String financialYearOf(final LocalDate date) {
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/**
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
*/
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
return totalIncome.subtract(totalExpense);
}
/**
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
* society-wide, so a second product's declaration competes for the same balance as the first.
*
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
* declaration is separately blocked outright.
*/
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
final StringBuilder sql = new StringBuilder("""
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
d.dividend_period_start_date, d.dividend_period_end_date, d.status
FROM m_share_product_dividend_pay_out d
JOIN m_share_product p ON p.id = d.product_id
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
""");
final List<Object> params = new ArrayList<>(List.of(to, from));
if (excludeProductId != null) {
sql.append(" AND d.product_id <> ? ");
params.add(excludeProductId);
}
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
final List<Map<String, Object>> declarations = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> declaration = new LinkedHashMap<>();
declaration.put("declarationId", row.get("declaration_id"));
declaration.put("productId", row.get("product_id"));
declaration.put("productName", row.get("product_name"));
declaration.put("amount", row.get("amount"));
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
declaration.put("status", row.get("status"));
declarations.add(declaration);
}
return declarations;
}
/**
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
* which is 0 — not a failure. Only an enabled row with a value applies.
*/
private BigDecimal configuredPercentage(final String configurationName) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return BigDecimal.ZERO;
}
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,180 @@
package org.apache.fineract.accounting.yearend.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.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.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
*
* <p>
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
*/
@Path("/v1/accounting/incomeprofittransfer")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class IncomeProfitTransferApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final IncomeProfitTransferService service;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// GET — how much is waiting to be posted, and what has been posted before
// ══════════════════════════════════════════════════════════════════════
@GET
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
@QueryParam("financialYear") final String financialYearParam) {
this.context.authenticatedUser();
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: incomeGlCodeParam.trim();
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
: financialYearParam.trim();
final LocalDate[] range = service.financialYearRange(financialYear);
final Long incomeGlId = toLong(incomeGl.get("id"));
final BigDecimal pending = service.balanceOf(incomeGlId, range);
final int entryCount = service.entryCountOf(incomeGlId, range);
final List<Map<String, Object>> history = jdbcTemplate
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("pendingAmount", pending);
out.put("entryCount", entryCount);
out.put("alreadyPosted", alreadyPosted);
out.put("postedHistory", history);
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — close the income GL into Bank Profit
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response post(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: getString(json, "incomeGlCode").trim();
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
// error, so the user is told why nothing happened.
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /close-year — close the WHOLE P&L into Bank Profit
// ══════════════════════════════════════════════════════════════════════
/**
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
*
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
*/
@POST
@Path("/close-year")
@Transactional
public Response closeYear(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
// told why nothing happened rather than getting a silent success.
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
}
}
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 String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -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.accounting.yearend.jobs.postshareentryfeetoprofit;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.jobs.service.JobName;
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 PostShareEntryFeeToProfitConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private IncomeProfitTransferService incomeProfitTransferService;
@Bean
protected Step postShareEntryFeeToProfitStep() {
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
}
@Bean
public Job postShareEntryFeeToProfitJob() {
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
}
}
@@ -0,0 +1,61 @@
/**
* 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.yearend.jobs.postshareentryfeetoprofit;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
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;
/**
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
* Profit equity GL, leaving the income account at nil for the new year.
*
* <p>
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
* rather than failing the job. Only a genuine posting failure is raised.
*/
@Slf4j
@RequiredArgsConstructor
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
private static final long SYSTEM_USER_ID = 1L;
private final IncomeProfitTransferService incomeProfitTransferService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final String financialYear = incomeProfitTransferService.currentFinancialYear();
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
// is now what the share dividend declaration reads as "the year's profit", and a partial close
// would leave most of the profit still sitting in the income GLs — understating what may be
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
"Automatic year-end closing", false);
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,377 @@
package org.apache.fineract.accounting.yearend.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Closes an income GL into the Bank Profit equity GL at financial year end.
*
* <pre>
* Dr Share Entry Fee Income (10.4) = balance for the year
* Cr Bank Profit (BANKPROFIT01)
* </pre>
*
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
* retained profit.
*
* <p>
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
* logged-in user.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IncomeProfitTransferService {
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int DEBIT = 2;
private static final int CREDIT = 1;
/** Equity GL that accumulates profit. */
/**
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
*
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
* the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
* leftovers, and the next declaration would be sized on profit that had already been through a distribution
* decision — offering the same money for distribution twice.
*/
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
/** Income GL closed to profit by the year-end job — the share entry fee. */
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
private final JdbcTemplate jdbcTemplate;
/**
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
* errors.
*
* @param strict
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
* returns quietly.
*/
@Transactional
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
final String note, final boolean strict) {
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final Long incomeGlId = toLong(incomeGl.get("id"));
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, incomeGlId, officeId);
if (existing != null && existing > 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "Already closed for " + financialYear);
return out;
}
final BigDecimal balance = balanceOf(incomeGlId, range);
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "No income to close for " + financialYear);
return out;
}
final int entryCount = entryCountOf(incomeGlId, range);
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 desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + incomeGl.get("name"));
jdbcTemplate.update(
"INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
financialYear);
out.put("posted", true);
out.put("amountPostedToProfit", balance);
out.put("entryCount", entryCount);
out.put("postedOnDate", postingDate.toString());
out.put("glTransactionId", txnId);
out.put("currency", currencyCode);
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
return out;
}
/**
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
* net profit.
*
* <pre>
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
* </pre>
*
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
* profit split across two places and understates it.
*
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
* Accounts with a nil balance are skipped rather than posted as zero entries.
*/
@Transactional
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
final boolean strict) {
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
// historical balance but must not attract new postings.
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
final List<Map<String, Object>> closed = new ArrayList<>();
final List<Map<String, Object>> skipped = new ArrayList<>();
BigDecimal totalIncome = BigDecimal.ZERO;
BigDecimal totalExpense = BigDecimal.ZERO;
for (final Map<String, Object> gl : plAccounts) {
final Long glId = toLong(gl.get("id"));
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, glId, officeId);
if (existing != null && existing > 0) {
skipped.add(skipRow(gl, "Already closed for " + financialYear));
continue;
}
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
// expense GL the natural balance is a debit, so the same figure comes back negative and
// is flipped here to read as a positive expense.
final BigDecimal rawBalance = balanceOf(glId, range);
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
continue;
}
final int entryCount = entryCountOf(glId, range);
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 descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
if (isIncome) {
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + gl.get("name"));
totalIncome = totalIncome.add(balance);
} else {
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
"Expense for " + financialYear + " from " + gl.get("name"));
totalExpense = totalExpense.add(balance);
}
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
java.sql.Date.valueOf(postingDate), userId, userName, note);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("type", isIncome ? "INCOME" : "EXPENSE");
row.put("amount", balance);
row.put("entryCount", entryCount);
row.put("glTransactionId", txnId);
closed.add(row);
}
if (closed.isEmpty() && strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
"There is nothing left to close to profit for " + financialYear + ".");
}
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
netProfit, profitGl.get("name"));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("totalIncomeClosed", totalIncome);
out.put("totalExpenseClosed", totalExpense);
out.put("netProfitPosted", netProfit);
out.put("accountsClosed", closed);
out.put("accountsSkipped", skipped);
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
+ netProfit + ".");
return out;
}
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("reason", reason);
return row;
}
/**
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
* year has been closed.
*
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
* whether that is a blocking condition.
*/
public BigDecimal profitForFinancialYear(final String financialYear) {
final LocalDate[] range = financialYearRange(financialYear);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** Whether the year's P&L has been closed into Bank Profit at all. */
public boolean isYearClosed(final String financialYear) {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
Integer.class, financialYear);
return count != null && count > 0;
}
/** Net credit balance sitting in the income GL for the year. */
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** How many fee postings made up that balance. */
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return count == null ? 0 : count;
}
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
public String currentFinancialYear() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/** 1 April to 31 March for a "YYYY-YY" label. */
public LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
"financialYear must look like 2026-27. Received: " + financialYear);
}
}
public Map<String, Object> loadGlByCode(final String glCode) {
try {
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
}
}
public Long headOfficeId() {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
}
private String resolveCurrency(final Long glAccountId) {
try {
final String code = jdbcTemplate.queryForObject(
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
glAccountId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
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, "
+ " 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, userId, userId,
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
accountsSummary.append("sa.deposit_type_enum as depositType ");
accountsSummary.append("sa.deposit_type_enum as depositType, ");
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
// Null for a savings/current account and for an FD opened before this column existed;
// callers fall back to accountNo, so an FD that has never been renewed still reads as
// its own FD number.
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null);
}
}
@@ -0,0 +1,299 @@
/**
* 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.accountregister.api;
import com.google.gson.Gson;
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 java.math.BigDecimal;
import java.sql.Date;
import java.time.LocalDate;
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.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Account register for one date: which accounts were opened and which were closed that day, with
* each account's balance as it stood on that date alongside what it holds now.
*
* The dated balance is read from the running balance stamped on the last transaction up to that
* date, rather than recomputed from the account's current figure — a current balance tells you
* nothing about what the account held three weeks ago.
*/
@Path("/v1/accountregister")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AccountRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
this.context.authenticatedUser();
final LocalDate asOn;
if (dateParam != null && !dateParam.isBlank()) {
try {
asOn = LocalDate.parse(dateParam);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
"Date must be in yyyy-MM-dd format.");
}
} else {
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
final List<Map<String, Object>> rows = new ArrayList<>();
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
rows.addAll(depositAccounts(asOn, want));
}
if (matches(want, "SHARE")) {
rows.addAll(shareAccounts(asOn));
}
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
// so the totals can never disagree with the list they summarise.
int opened = 0;
int closed = 0;
BigDecimal balanceOnDate = BigDecimal.ZERO;
BigDecimal currentBalance = BigDecimal.ZERO;
for (final Map<String, Object> r : rows) {
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
opened++;
}
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
closed++;
}
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("date", asOn.toString());
out.put("section", want);
out.put("accounts", rows);
out.put("count", rows.size());
out.put("openedCount", opened);
out.put("closedCount", closed);
out.put("totalBalanceOnDate", balanceOnDate);
out.put("totalCurrentBalance", currentBalance);
return gson.toJson(out);
}
private static boolean matches(final String want, final String section) {
return "ALL".equals(want) || want.equals(section);
}
private static BigDecimal dec(final Object o) {
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
}
/**
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
*
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
* them would make a register of a past day describe a present one.
*/
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
final Date date = Date.valueOf(asOn);
final StringBuilder typeFilter = new StringBuilder();
if (!"ALL".equals(want)) {
final int type = switch (want) {
case "SAVINGS" -> 100;
case "FD" -> 200;
case "RD" -> 300;
default -> 0;
};
if (type > 0) {
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
}
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
sa.account_balance_derived,
c.display_name AS client_name,
p.name AS product_name,
(SELECT t.running_balance_derived
FROM m_savings_account_transaction t
WHERE t.savings_account_id = sa.id
AND COALESCE(t.is_reversed, false) = false
AND t.transaction_date <= ?
ORDER BY t.transaction_date DESC, t.id DESC
LIMIT 1) AS balance_on_date
FROM m_savings_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_savings_product p ON p.id = sa.product_id
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
""" + typeFilter + """
ORDER BY sa.deposit_type_enum, sa.account_no
""", date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
final int depositType = num(r.get("deposit_type_enum"));
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", savingsStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activatedon_date")));
m.put("closedOn", str(r.get("closedon_date")));
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
// No transaction up to that date means the account had nothing in it yet, not that the
// balance is unknown.
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
m.put("currentBalance", r.get("account_balance_derived") == null
? BigDecimal.ZERO : r.get("account_balance_derived"));
out.add(m);
}
return out;
}
/**
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
* are individually dated, so the holding on a given date is the shares purchased up to that
* date less those redeemed — which is a real dated figure, not the current one relabelled.
*/
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
final Date date = Date.valueOf(asOn);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
sa.total_approved_shares,
c.display_name AS client_name,
p.name AS product_name,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)) AS share_value,
-- Purchases less redemptions dated on or before the register's date. Charge
-- rows (type 700) are excluded: an entry fee is not share capital. Only
-- approved purchases count (status 300) — a rejected application leaves its
-- row behind still flagged active, and counting it credited a member with
-- shares they were never issued.
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS value_on_date,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
WHEN t.type_enum = 600 THEN -t.total_shares
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS shares_on_date
FROM m_share_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_share_product p ON p.id = sa.product_id
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
ORDER BY sa.account_no
""", date, date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", "SHARE");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", shareStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activated_date")));
m.put("closedOn", str(r.get("closed_date")));
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
// Shares as at the date, not the approved figure — an account can hold fewer than were
// approved once some have been redeemed.
m.put("sharesHeld", r.get("shares_on_date"));
out.add(m);
}
return out;
}
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
return dbDate != null && asOn.toString().equals(dbDate.toString());
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
private static int num(final Object o) {
return o instanceof Number n ? n.intValue() : 0;
}
private static String savingsStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
case 700 -> "TRANSFER IN PROGRESS";
case 800 -> "TRANSFER ON HOLD";
case 900 -> "PREMATURELY CLOSED";
default -> "UNKNOWN";
};
}
private static String shareStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
default -> "UNKNOWN";
};
}
}
@@ -113,6 +113,37 @@ public interface ShareAccountApiConstants {
* outstanding balance. */
String linkedloanid_paramname = "linkedLoanId";
/**
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
*
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
* differ only in how it got there: CASH means the cashier already took it at the counter
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
*/
String fundingsource_paramname = "fundingSource";
String FUNDING_SOURCE_CASH = "CASH";
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
/**
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
*
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
* see which receipt the money they are releasing came in on.
*/
String advancecollectionid_paramname = "advanceCollectionId";
/**
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
*
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
* rest, and their combined total is what has to cover the shares.
*/
String additionalcollectionids_paramname = "additionalCollectionIds";
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,
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
@@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
* transaction that has already succeeded. Joining the caller's transaction would let a failed
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
* contained to AML.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
@@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -67,8 +70,13 @@ import com.google.common.base.Splitter;
@Component
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
@RequiredArgsConstructor
@lombok.extern.slf4j.Slf4j
public class CashierDepositRequestApiResource {
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
// activity JsonArray, and a field of the same name reads as that variable at a glance.
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
@@ -161,7 +169,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
currentUser.getId(), currentUser.getUsername());
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -394,6 +402,20 @@ public class CashierDepositRequestApiResource {
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: 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
// hit an edge case in the new hold-placement logic would fail entirely, where before
// this feature existed it always succeeded. Catching it keeps the Clearing Hold strictly
// best-effort: the deposit approval this whole endpoint exists for must never depend on
// it, only benefit from it when it works.
try {
placeClearingHoldIfInstrumentDeposit(savingsAccountId, ptId, amount, txnDateStr);
} catch (final RuntimeException e) {
log.warn("Could not place a Clearing Hold for cashier deposit request {} (savings account {}): {}", id,
savingsAccountId, e.getMessage(), e);
}
}
}
// Link voucher to the created savings txn and stamp voucher_no on it.
@@ -406,6 +428,10 @@ public class CashierDepositRequestApiResource {
}
}
// The sides of the slip, recorded now rather than at submission: which accounts the money
// actually moved between is only settled once the request is approved and posted.
addDepositVoucherLines(record, savingsAccountId, amount);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
);
@@ -540,6 +566,62 @@ public class CashierDepositRequestApiResource {
// ── helpers ──────────────────────────────────────────────────────────────────
/**
* Records the two sides of an approved deposit on its voucher.
*
* A deposit is money arriving at the counter, so cash is debited and whatever receives it is
* credited — a member's savings account, or a GL account when the deposit was raised against
* one. The credited side is read from the request, because that is what the cashier actually
* filled in and what the member signed for.
*
* A failure here does not fail the approval. The deposit is already posted and the member's
* balance already raised; refusing that over its slip would take a correct transaction back for
* a paperwork reason. The problem is logged, and the voucher keeps its number without sides.
*/
private void addDepositVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type"));
final String accountNo = (String) record.get("account_no");
// Cash reaching the counter. For a GL deposit the contra is the cash/vault account the
// request named; for a member deposit it is the cashier's own till, which the deposit
// register does not carry a GL id for — the line still names it, without an id.
final Object contraObj = record.get("contra_gl_id");
final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null;
final Map<String, Object> contra = glAccountForVoucher(contraGlId);
final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"),
(String) contra.get("gl_code"), amount);
final VoucherLine credit;
if (isGl) {
final Object glObj = record.get("gl_account_id");
final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null;
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);
}
voucherService.addLines(voucherNo, List.of(debit, credit));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e);
}
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
}
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
.orElse(Map.of());
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, account_no, account_type, savings_account_id,
@@ -586,6 +668,54 @@ public class CashierDepositRequestApiResource {
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
* until the instrument clears rather than let it sit withdrawable, so this places an automatic
* Clearing Hold for the full deposited amount right after a Cheque/DD deposit posts. Cash and
* electronic-transfer payment types (NEFT/RTGS/IMPS/UPI) are untouched -- cash is available on the
* spot, and an electronic transfer has already settled by the time it's entered here.
*/
private void placeClearingHoldIfInstrumentDeposit(final Long savingsAccountId, final Object paymentTypeId, final BigDecimal amount,
final String txnDateStr) {
if (paymentTypeId == null) {
return;
}
final Long ptId = ((Number) paymentTypeId).longValue();
// m_payment_type's display name column is "value", not "name" (the mistake that broke this
// in testing) -- name() collisions with other tables made this easy to get wrong once and not
// notice until it actually ran against the real schema.
final List<String> names = jdbcTemplate.query("SELECT value FROM m_payment_type WHERE id = ?",
(rs, rowNum) -> rs.getString("value"), ptId);
if (names.isEmpty()) {
return;
}
final String name = names.get(0).toLowerCase();
final boolean isInstrumentDeposit = name.contains("cheque") || name.contains("check") || name.equals("dd")
|| name.contains("demand draft");
if (!isInstrumentDeposit) {
return;
}
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);
payload.addProperty("reasonForBlock", "Pending clearing — " + names.get(0) + " deposit");
payload.addProperty("holdCategory", "CLEARING");
payload.addProperty("lienAllowed", false);
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.holdAmount(savingsAccountId)
.withJson(payload.toString())
.build();
commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private String executeCurrentAccountDeposit(
final Long currentAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
@@ -685,7 +815,10 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The deposit register raises its own voucher, naming the member and their account; the
// journal command must not raise a second one over the same money.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getTransactionId() != null ? result.getTransactionId() : null;
}
@@ -699,7 +832,7 @@ public class CashierDepositRequestApiResource {
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo,
final Long clientId, final Long cashierId, final String cashierUsername) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -729,12 +862,12 @@ public class CashierDepositRequestApiResource {
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
""",
requestId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashierId, cashierUsername
cashierId, cashierUsername, paymentTypeName
);
}
}
@@ -15,6 +15,7 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -30,8 +31,217 @@ public class CashReportApiResource {
private final PlatformSecurityContext context;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Day book — every voucher raised on one date, in one list, newest first.
*
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
* without opening three separate screens.
*
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
* has no such column — falls back to the date it was submitted on.
*/
@GET
@Path("day-book")
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
@QueryParam("status") final String status) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final List<Map<String, Object>> rows = new ArrayList<>();
// Each register is left-joined to the verification table and to an attachment count, so a
// row already carries its physical-check verdict and whether a slip was scanned in — the
// screen never has to fire a follow-up call per voucher.
final String verifyJoin = """
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = r.voucher_no
""";
final String verifyCols = """
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
vv.verified_by_name, vv.verified_at,
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
COALESCE(va.n, 0) AS attachment_count
""";
// Cashier deposits — money in, so the credit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_cashier_deposit_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Clerk withdrawals — money out, so the debit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_withdrawal_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Account closures — a full-balance payout, so the debit side. Its own register rather than
// the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever
// close() computes at approval) and the cash handed over is the ROUNDED
// cashier_disbursed_amount, which is what a cash book has to agree with, not the
// paisa-exact closing_amount posted to the member's ledger.
//
// Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the
// cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has
// taken nothing out of the drawer yet, and a REJECTED one never will — listing those would
// put rows with no cash behind them (a rejected closure has no amount at all) into the
// teller's day book. And the day they belong to is the day the cash left, so this filters on
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
// step to reach.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no,
'SAVINGS' AS account_type, r.client_name AS party,
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
r.payment_type_name, r.description, r.status,
COALESCE(r.disbursed_by, r.clerk_name) AS handled_by,
r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_closure_request r
""" + verifyJoin + """
WHERE r.status = 'CLOSED'
AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
r.description, r.status, r.clerk_name AS handled_by,
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_disbursement_request r
""" + verifyJoin + """
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
// ordered as one result set, and the row count for a single day is small.
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
final List<Map<String, Object>> filtered = new ArrayList<>();
BigDecimal creditTotal = BigDecimal.ZERO;
BigDecimal debitTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
continue;
}
filtered.add(row);
// Only approved money is real money: a pending or rejected voucher must not inflate the
// day's totals, even though it still belongs in the listing. The source registers don't
// share one terminal status string: deposits and disbursements land on APPROVED, a clerk
// withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing
// any of them meant a completed transaction moved real money out yet never showed up in
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
// both states cannot double-count it; only 'CLOSED' is unique to that register.
final String rowStatus = (String) row.get("status");
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
if ("CR".equals(row.get("side"))) {
creditTotal = creditTotal.add(amount);
} else {
debitTotal = debitTotal.add(amount);
}
}
}
}
filtered.sort((a, b) -> {
final Object left = b.get("submittedAt");
final Object right = a.get("submittedAt");
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.toString().compareTo(right.toString());
});
final Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", filtered);
result.put("count", filtered.size());
result.put("creditTotal", creditTotal);
result.put("debitTotal", debitTotal);
result.put("netTotal", creditTotal.subtract(debitTotal));
return gson.toJson(result);
}
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no"));
row.put("side", rs.getString("side"));
row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no"));
row.put("accountType", rs.getString("account_type"));
row.put("party", rs.getString("party"));
row.put("amount", rs.getBigDecimal("amount"));
row.put("paymentTypeName", rs.getString("payment_type_name"));
row.put("description", rs.getString("description"));
row.put("status", rs.getString("status"));
row.put("handledBy", rs.getString("handled_by"));
row.put("approvedBy", rs.getString("approved_by"));
row.put("txnId", rs.getString("txn_id"));
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
row.put("verifyStatus", rs.getString("verify_status"));
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
row.put("verifyRemarks", rs.getString("verify_remarks"));
row.put("verifiedByName", rs.getString("verified_by_name"));
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
// How the exception ended, if it has. Without this the day book keeps showing a closed
// exception as though it were still open — the flag never clears on screen.
row.put("resolutionStatus", rs.getString("resolution_status"));
row.put("resolutionNotes", rs.getString("resolution_notes"));
row.put("resolvedByName", rs.getString("resolved_by_name"));
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
row.put("attachmentCount", rs.getInt("attachment_count"));
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
// form the browser cannot parse reliably.
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
return row;
}
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
@@ -360,6 +570,30 @@ public class CashReportApiResource {
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
/**
* GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the
* bank have", as opposed to cashier-position which answers the same question for a cashier.
* See {@link VaultPositionService} for how the anchor is chosen.
*/
@GET
@Path("vault-position")
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
@QueryParam("asOfDate") final String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
if (tellerId == null) {
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
final List<Map<String, Object>> all = new ArrayList<>();
for (final Long id : tellerIds) {
all.add(vaultPositionService.buildPositionForVault(id, dateFilter));
}
return gson.toJson(all);
}
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
@@ -597,9 +831,44 @@ public class CashReportApiResource {
baseParams = new Object[] { txnDate };
}
// Base grouped query (reused for count, totals, and paged data)
// Base grouped query (reused for count, totals, and paged data). The two LEFT JOINs resolve
// the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/
// withdrawal request at approval time -- std.txn_id on its own is only the internal savings
// transaction id (e.g. "4"), never what was actually printed on the voucher.
//
// A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry
// reference) rather than a plain decimal savings-transaction id, but std.txn_id is always
// stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the
// hex string's numeric value (178807899015311), not the hex string itself, and a plain
// string-equality join against cdr.txn_id can never match it. The second half of each OR
// below re-derives that same decimal value from the hex string to match it -- guarded to
// only run on strings that are actually hex (so an ordinary decimal txn_id, which never
// contains a-f, is never sent through the hex cast at all).
final String baseGroupSql = "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
// A GL-type row (no client_id) carries the GL account's own code in account_no
// rather than a savings/current account number, so its "name" has to come from the
// chart of accounts, not from a client that was never party to the transaction.
+ "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no "
// Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's
// decimal txn_id can coincidentally hex-decode to the exact same number (confirmed
// live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to
// 178810369551311), and an unscoped join matched the withdrawal row to that unrelated
// deposit's voucher, which COALESCE then preferred over the withdrawal's own real one.
+ "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND "
+ " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND "
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
// An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row
// (the column records which way cash moved), but its request lives in its own
// register -- without this join the closure's real voucher number is never found and
// the report falls back to showing the bare savings-transaction id instead.
+ "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND "
+ " ccr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ officerFilter
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
@@ -620,9 +889,10 @@ public class CashReportApiResource {
// 2. Paged data query
final String dataSql = "SELECT "
+ " std.txn_id AS voucher_no, "
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " std.account_no AS ac_no, "
+ " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, "
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
+ " MAX(std.txn_date) AS txn_date, "
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
+ " MAX(std.officer_username) AS teller_name, "
@@ -642,7 +912,7 @@ public class CashReportApiResource {
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getLong("voucher_no"));
row.put("voucherNo", rs.getString("voucher_no"));
row.put("acNo", rs.getString("ac_no"));
row.put("acName", rs.getString("ac_name"));
row.put("txnDate", rs.getString("txn_date"));
@@ -0,0 +1,169 @@
/**
* 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.cashreport.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
/**
* Denomination-wise cash a vault (teller) is currently holding.
*
* Anchored on the most recent business day (on or before the target date) whose branch day
* register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact
* teller), then walked forward by everything settled into this vault and handed out of it since.
* Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows
* (which mix the vault with whatever every cashier under it is holding) precisely so this anchor
* never includes cash that still physically sits with a cashier — mixing the two double-counts
* whatever a cashier settles back later, once already folded into the branch total, again as the
* settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT}
* row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor
* still returns something rather than zero.
*/
@Component
@RequiredArgsConstructor
public class VaultPositionService {
private final JdbcTemplate jdbcTemplate;
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final RowMapper<Object[]> DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"),
rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") };
public Map<String, Object> buildPositionForVault(final Long tellerId, final String dateFilter) {
final Map<String, Object> result = new LinkedHashMap<>();
result.put("tellerId", tellerId);
result.put("asOfDate", dateFilter);
String tellerName = null;
Long officeId = null;
try {
final Map<String, Object> teller = jdbcTemplate.queryForMap("SELECT name, office_id FROM m_tellers WHERE id = ?",
tellerId);
tellerName = (String) teller.get("name");
officeId = ((Number) teller.get("office_id")).longValue();
} catch (final Exception ignored) {
// intentionally ignored
}
result.put("tellerName", tellerName);
if (officeId == null) {
result.put("hasBreakdown", false);
result.put("totalAmount", BigDecimal.ZERO);
result.put("denominations", new ArrayList<>());
return result;
}
// Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried
// forward day to day, with no cashier's held cash folded in. Fall back to the old
// branch-combined OPENING leg only when this office has no vault-scoped row at all on or
// before the target date (a day closed before vault-only tracking existed).
List<Map<String, Object>> anchorRows = jdbcTemplate.queryForList("""
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
WHERE d.register_id = r.id AND d.leg = 'OPENING_VAULT' AND d.teller_id = ?)
ORDER BY r.business_date DESC LIMIT 1
""", officeId, dateFilter, tellerId);
String anchorLeg = "OPENING_VAULT";
if (anchorRows.isEmpty()) {
anchorLeg = "OPENING";
anchorRows = jdbcTemplate.queryForList("""
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
WHERE d.register_id = r.id AND d.leg = 'OPENING')
ORDER BY r.business_date DESC LIMIT 1
""", officeId, dateFilter);
}
if (anchorRows.isEmpty()) {
result.put("hasBreakdown", false);
result.put("totalAmount", BigDecimal.ZERO);
result.put("denominations", new ArrayList<>());
return result;
}
final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue();
final String anchorDate = anchorRows.get(0).get("business_date").toString();
final Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query("""
SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count
FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id
WHERE d.register_id = ? AND d.leg = ? AND (d.teller_id IS NULL OR d.teller_id = ?)
""", DENOM_ROW, registerId, anchorLeg, tellerId), 1);
addToBalance(balance, jdbcTemplate.query("""
SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count
FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id
WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)
AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE)
GROUP BY csd.denomination_id, csd.denomination_value, dm.kind
""", DENOM_ROW, tellerId, anchorDate, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query("""
SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count
FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id
WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE)
GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind
""", DENOM_ROW, tellerId, anchorDate, dateFilter), -1);
final List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])).forEach(e -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("count", e.getValue()[2]);
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
final BigDecimal totalAmount = rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO,
BigDecimal::add);
result.put("hasBreakdown", true);
result.put("anchorDate", anchorDate);
result.put("totalAmount", totalAmount);
result.put("denominations", rows);
return result;
}
private void addToBalance(final Map<Long, long[]> balance, final List<Object[]> rows, final int sign) {
for (final Object[] row : rows) {
final Long denomId = (Long) row[0];
final int denomValue = (Integer) row[1];
final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
final long count = (Long) row[3];
final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
}
@@ -98,6 +98,8 @@ public class CashTransferApiResource {
"At least one denomination with a positive count is required.");
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
header.setFromParty(fromParty);
@@ -264,6 +266,127 @@ public class CashTransferApiResource {
return total;
}
/**
* A vault can only hand out cash it is actually holding — in total, and note-by-note. The
* branch's business day carries an opening_cash figure forward each day (see
* BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is
* that opening figure, plus anything a cashier has already settled back into this same vault
* today, minus whatever this vault has already handed out to any cashier today. Reversed
* transfers/settlements don't count.
*
* No open business day for the vault's office means there is no opening_cash to check against, so
* the transfer is blocked outright rather than silently allowed against an unknown balance.
*/
private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount,
final JsonArray denominations, final LocalDate transferDate) {
final Long officeId;
try {
officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId);
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found",
"Vault/teller " + fromTellerId + " was not found.");
}
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
final List<Long> registerIds = jdbcTemplate.query("""
SELECT id FROM m_branch_day_register
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
""", (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate);
if (registerIds.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open",
"The business day for this branch is not open for " + businessDate
+ ". Open the business day before allocating cash to a cashier.");
}
final Long registerId = registerIds.get(0);
final BigDecimal openingCash = jdbcTemplate.queryForObject(
"SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId);
final BigDecimal handedOutToday = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
final BigDecimal settledBackToday = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday);
if (requestedAmount.compareTo(available) > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash",
"Insufficient vault cash: available " + available + ", requested " + requestedAmount + ".");
}
validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate);
}
/**
* The total-amount check above can pass while a specific note/coin still runs short — a vault
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
* total is well within its balance. This checks each requested denomination against what the
* vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's
* settlements-in minus today's transfers-out actually holds of that specific denomination.
*
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a
* denomination against, so this is skipped rather than blocking every transfer on a day that
* simply never captured one.
*/
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
final JsonArray denominations, final java.sql.Date businessDate) {
final List<Object[]> openingRows = jdbcTemplate.query("""
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail
WHERE register_id = ? AND leg = 'OPENING'
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
if (openingRows.isEmpty()) {
return;
}
final Map<Long, Integer> available = new java.util.HashMap<>();
for (final Object[] row : openingRows) {
available.merge((Long) row[0], (Integer) row[2], Integer::sum);
}
final List<Object[]> settledRows = jdbcTemplate.query("""
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
GROUP BY csd.denomination_id
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
for (final Object[] row : settledRows) {
available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum);
}
final List<Object[]> handedOutRows = jdbcTemplate.query("""
SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd
JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
GROUP BY ctd.denomination_id
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
for (final Object[] row : handedOutRows) {
available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum);
}
for (final JsonElement el : denominations) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
continue;
}
final long denominationId = item.get("denominationId").getAsLong();
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
final int availableCount = available.getOrDefault(denominationId, 0);
if (count > availableCount) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination",
"Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount
+ ", requested " + count + ".");
}
}
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
@@ -0,0 +1,883 @@
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.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.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.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.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
*
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
* flow.
*
* <p>
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
*
* <pre>
* Dr Cash at Vault (SHARES_REFERENCE) = amount
* Cr Share Sundry (SHARES_SUSPENSE) = amount
* </pre>
*
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
*
* <p>
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
*
* <pre>
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
* Cr Share Capital (SHARES_EQUITY) = share amount
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
* </pre>
*
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
*
* <p>
* <b>When the cash does not match</b>
*
* <p>
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
* argued with at the window:
*
* <ul>
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
* back to them.</li>
* </ul>
*/
@Path("/v1/shareadvancecollection")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareAdvanceCollectionApiResource {
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
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;
private static final int FA_SHARES_EQUITY = 4;
// ── 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_ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// POST — Take cash from a member with no share account yet
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response collect(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long clientId = getLong(json, "clientId");
final BigDecimal amount = getBigDecimal(json, "amount");
final String note = sanitiseNote(getString(json, "note"));
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
}
final Map<String, Object> client = loadClient(clientId);
final Long officeId = toLong(client.get("office_id"));
final String clientName = (String) client.get("display_name");
// The share product drives the GL mapping. Collection happens before an account exists,
// so the product is supplied by the caller or defaults to the only mapped share product.
final Long productId = resolveProductId(getLong(json, "productId"));
final String currencyCode = resolveCurrency(productId);
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHADV-" + System.nanoTime();
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
"Share advance held in Sundry | Receipt " + receiptNo);
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
currentUser.getUsername(), note);
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
receiptNo);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", id);
out.put("receiptNo", receiptNo);
out.put("clientId", clientId);
out.put("clientName", clientName);
out.put("amount", amount);
out.put("currency", currencyCode);
out.put("status", "UNAPPLIED");
out.put("glTransactionId", txnId);
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
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");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
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();
}
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// 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
// firewall with a 400 before the request reaches this resource. The older
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
@GET
@Path("/by-voucher")
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
@QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) {
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
"A voucher or receipt number is required.");
}
return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride);
}
@GET
@Path("/by-voucher/{voucherNo}")
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint,
@QueryParam("glAccountNo") final String glAccountNoOverride) {
this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ " 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);
if (rows.isEmpty()) {
// Cash is normally taken through the cashier's Transaction Entry deposit into the GL
// account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt
// that approved deposit as a collection so the member's voucher resolves here. Which GL
// account to look in is never guessed from product configuration — the counter selects it
// by hand every time, since a product mapping can silently go missing or point at the
// wrong account and a plain voucher lookup should never depend on that being right.
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
if (adopted != null) {
return Response.ok(gson.toJson(adopted)).build();
}
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
+ voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the "
+ "deposit has been approved by a manager.");
}
final Map<String, Object> row = rows.get(0);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
}
return Response.ok(gson.toJson(row)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — GL accounts selectable for a by-voucher lookup
// ══════════════════════════════════════════════════════════════════════
/**
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
*
* 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.
*/
@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");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — What a share account needs before a collection can be applied
// ══════════════════════════════════════════════════════════════════════
/**
* Share value plus any outstanding entry fee for one share account -- shown up front on the Apply
* Collection screen so the person at the counter knows the full amount to collect (and that it may
* come in as more than one receipt, e.g. one for the share value and a separate one for the fee)
* before a search ever comes up short, rather than learning it only from an error after the fact.
*/
@GET
@Path("/requirement")
public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
final Map<String, Object> acct = loadAccount(shareAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("accountNo", acct.get("account_no"));
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("required", shareAmount.add(chargeAmount));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{collectionId}/apply")
@Transactional
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long shareAccountId = getLong(json, "shareAccountId");
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
// A member who pays in instalments has more than one receipt against the same application:
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
// are applied together as one settlement. The path receipt leads; any others come in the
// body. These are the SHARE-side receipts only -- money that actually sat in Sundry and
// genuinely needs to move out of it into Share Capital.
final List<Long> collectionIds = new java.util.ArrayList<>();
collectionIds.add(collectionId);
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
if (!collectionIds.contains(additionalId)) {
collectionIds.add(additionalId);
}
}
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
for (final Long id : collectionIds) {
final Map<String, Object> row = loadCollection(id);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
}
collections.add(row);
}
Map<String, Object> chargeColl = null;
if (chargeCollectionId != null) {
chargeColl = loadCollection(chargeCollectionId);
if (!"UNAPPLIED".equals(chargeColl.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + ".");
}
}
final Map<String, Object> coll = collections.get(0);
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") + ".");
}
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
}
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
"Share account " + acct.get("account_no") + " has already been settled.");
}
// Share value the account was opened for, and any entry fee attached to it.
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
// When the fee is being verified as its own receipt, the Sundry-side receipts only need to
// cover the share value -- the fee is proved paid separately below, and its money is never
// expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is
// assumed bundled into the same Sundry receipt(s) as the share value, exactly as before.
final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount);
BigDecimal receiptAmount = BigDecimal.ZERO;
final StringBuilder receiptNos = new StringBuilder();
for (final Map<String, Object> row : collections) {
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
if (receiptNos.length() > 0) {
receiptNos.append(" + ");
}
receiptNos.append(row.get("receipt_no"));
}
// Short is blocked: share capital cannot be part-issued — the member either bought those
// shares or did not — so settling for less would leave the account short by an amount owed
// by nobody. The clerk collects the balance on another receipt and applies both together.
if (receiptAmount.compareTo(requiredForShareReceipts) < 0) {
final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
+ requiredForShareReceipts + ". Collect the remaining " + shortfall
+ " from the member, then apply both receipts together.");
}
// The fee receipt, when given, must cover the fee on its own -- checked separately from the
// share-side shortfall above, since the two are no longer one combined pot.
if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount");
if (chargeReceiptAmount.compareTo(chargeAmount) < 0) {
final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short",
"Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account "
+ acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining "
+ shortfall + " from the member.");
}
}
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
// The difference is credited to their linked savings account below. Only the share-side
// excess is handled here -- an over-payment on a separately-verified fee receipt would need
// its own GL leg to fund the savings credit, which this flow does not post (that receipt's
// money is left exactly where the cashier already put it), so it is left uncollected rather
// than silently unbalancing the ledger.
final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts);
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
+ " requires only " + requiredForShareReceipts + ". 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.");
}
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 Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
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 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);
// 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.
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);
}
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);
}
// The over-payment goes back to the member as a savings deposit rather than a bare journal
// entry, so it lands in their passbook and running balance the way any counter deposit
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
Long excessSavingsTransactionId = null;
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
}
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
// Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit
// table regardless of which GL account they were posted to, so no GL account lookup is needed
// here either. Covers the fee receipt's own deposit too, when one was verified.
for (final Map<String, Object> row : collections) {
if (row.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"));
}
}
if (chargeColl != null && chargeColl.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no"));
}
// 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.
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);
}
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);
}
// Reveals the purchase transaction in the passbook and transactions views.
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", collectionId);
out.put("collectionIds", collectionIds);
out.put("receiptNo", receiptNos.toString());
out.put("shareAccountId", shareAccountId);
out.put("accountNo", accountNo);
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("chargeReceiptVerifiedSeparately", chargeColl != null);
out.put("totalApplied", requiredForShareReceipts);
out.put("totalCollected", receiptAmount);
out.put("glTransactionId", txnId);
out.put("status", "APPLIED");
// Told at the counter, while the member is still there, rather than left to be noticed on
// a statement later.
out.put("excessAmount", excessAmount);
if (excessSavingsTransactionId != null) {
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
+ "their savings account.");
}
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
*
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
* so the voucher resolves and can be applied.
*
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
* is what keeps the money tied to the right member.
*
* @param glAccountNoOverride
* required -- the GL account number the deposit was posted to, selected by the counter
* rather than resolved from any product's accounting configuration, so a broken or
* missing product mapping can never make a genuine voucher unreachable.
* @return the newly created collection row, or null when no approved deposit matches.
*/
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",
"Select the GL account this voucher was deposited to, then search again.");
}
final String shareSundryAccountNo = glAccountNoOverride.trim();
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"
+ " FROM m_cashier_deposit_request d"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, shareSundryAccountNo);
if (deposits.isEmpty()) {
return null;
}
final Map<String, Object> dep = deposits.get(0);
if (toLong(dep.get("share_account_id")) != null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// 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;
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
+ " so it cannot be matched. Re-enter the deposit against the member.");
}
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
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();
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);
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"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
private Long loadClientOfficeId(final Long clientId) {
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
}
/** Share value the account was opened for: the pending/approved purchase transaction. */
private BigDecimal shareValueOf(final Long shareAccountId) {
final BigDecimal total = jdbcTemplate
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
/** Entry fee still outstanding on the account, zero when no charge was attached. */
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
// created before the flat-charge fix carry the flag set to true while the full amount is
// still outstanding, and those charges must still be settled by the collection.
final BigDecimal total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
private Map<String, Object> loadCollection(final Long id) {
try {
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
"Share advance collection " + id + " does not exist.");
}
}
private Map<String, Object> loadClient(final Long clientId) {
try {
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
}
}
private Map<String, Object> loadAccount(final Long shareAccountId) {
try {
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
"Share account " + shareAccountId + " does not exist.");
}
}
/**
* Uses the caller's product when given, else the single mapped share product.
*
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
* when only one real share product is mapped.
*/
private Long resolveProductId(final Long requested) {
if (requested != null) {
return requested;
}
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
if (ids.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
"No share product has GL mappings configured.");
}
if (ids.size() > 1) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
"productId is required: " + ids.size() + " share products have GL mappings.");
}
return ids.get(0);
}
private String resolveCurrency(final Long productId) {
try {
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
productId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
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 = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (IncorrectResultSizeDataAccessException e) {
return null;
}
}
private String nextReceiptNo() {
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
return String.format("SHADV%06d", seq == null ? 1 : seq);
}
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) {
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,
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 JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
}
}
private LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
try {
return LocalDate.parse(raw);
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
}
}
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 String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long getLong(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
}
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
}
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
private List<Long> getLongList(final JsonObject json, final String field) {
final List<Long> values = new java.util.ArrayList<>();
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
return values;
}
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
if (!element.isJsonNull()) {
values.add(element.getAsLong());
}
}
return values;
}
/**
* Credits an over-payment to the member's savings account.
*
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
* would move the balance in the ledger while leaving the member's own account history silent about it.
*/
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", resolveExcessPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
}
/**
* The payment type this deposit is recorded under.
*
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
*/
private Long resolveExcessPaymentTypeId() {
final List<Long> byName = jdbcTemplate.queryForList(
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
if (!byName.isEmpty()) {
return byName.get(0);
}
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
if (any.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
}
return any.get(0);
}
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;
}
}
@@ -375,8 +375,20 @@ public class ShareCollectionApiResource {
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
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,
@@ -35,6 +35,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.charge.domain.Charge;
@@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService {
private final FineractRequestContextHolder fineractRequestContextHolder;
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
private final SavingsHelper savingsHelper;
private final PlatformSecurityContext context;
private final VoucherService voucherService;
/** A cheque book issuance charge, keyed by its m_cheque_book id. */
private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE";
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
private FacilityChargeMapping resolveMapping() {
@@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService {
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId);
}
@@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService {
this.currentAccountRepository.saveAndFlush(account);
}
/**
* Records a cheque book issuance charge in the voucher register.
*
* The account is debited and the charge's income account credited — the society's fee, taken
* from the customer. The debit names the customer and their account number even though it posts
* against the product's control GL, so the slip reads as the customer's own.
*
* A failure here does not fail the charge. The money is already taken and the ledger already
* posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The
* problem is logged and the charge stands.
*/
private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId,
final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) {
try {
final Map<String, Object> holder = accountHolderForVoucher(accountType, accountId);
final Map<String, Object> incomeGl = this.voucherService.glAccount(incomeGlId);
final String narration = "Cheque book issuance charge - book #" + chequeBookId;
this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId,
this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT,
List.of(VoucherLine
.debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount)
.withDescription(narration),
VoucherLine
.creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount)
.withDescription(narration)));
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e);
}
}
/** The customer and account number a charge is taken from, for printing on a voucher. */
private Map<String, Object> accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) {
final String sql = accountType == ChequeAccountType.SAVINGS
? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"
: "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
private Long getSavingsControlGlId(final Long savingsAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
@@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService {
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
// The charge raises its own voucher, naming the account charged and the fee; the journal
// command must not raise a second one over the same money.
final CommandProcessingResult result = this.voucherService
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
if (result == null) {
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
return;
@@ -0,0 +1,133 @@
/**
* 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.clearing.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing
* role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are
* fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads
* them and updates which GL account one points at.
*/
@Path("/v1/clearingglconfig")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ClearingGlConfigApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code
FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id
ORDER BY c.config_key
""");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toResult(row));
}
return gson.toJson(out);
}
@GET
@Path("template")
public String template() {
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT id, name, gl_code FROM acc_gl_account
WHERE disabled = false AND manual_journal_entries_allowed = true
ORDER BY name
""");
final List<Map<String, Object>> options = new ArrayList<>();
for (final Map<String, Object> a : accounts) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", a.get("id"));
o.put("name", a.get("name"));
o.put("glCode", a.get("gl_code"));
options.add(o);
}
return gson.toJson(Map.of("glAccountOptions", options));
}
@PUT
@Path("{configKey}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("configKey") final String configKey, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG");
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?",
Integer.class, configKey);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found",
"Clearing GL config key '" + configKey + "' was not found.");
}
final var root = this.fromJsonHelper.parse(body);
final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root);
if (glAccountId != null) {
final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?",
Integer.class, glAccountId);
if (accountExists == null || accountExists == 0) {
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found",
"GL account " + glAccountId + " was not found.");
}
}
jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId,
configKey);
return retrieveAll();
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("id", row.get("id"));
out.put("configKey", row.get("config_key"));
out.put("description", row.get("description"));
out.put("glAccountId", row.get("gl_account_id"));
out.put("glAccountName", row.get("gl_account_name"));
out.put("glCode", row.get("gl_code"));
return out;
}
}
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
@@ -32,6 +33,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -49,6 +53,11 @@ public class ClearingJournalService {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final VoucherService voucherService;
/** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */
private static final String REF_CLEARING_POSTING = "CLEARING_POSTING";
/**
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
@@ -57,6 +66,19 @@ public class ClearingJournalService {
*/
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null);
}
/**
* As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT.
*
* Savings and current accounts number independently, so the same id exists in both tables and the id
* alone cannot say whose account it is. A caller that knows the type passes it and the voucher names
* the right customer; one that has no account at all — a settlement summary — passes neither.
*/
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId,
final String linkAccountType) {
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
creditGlId, amount);
@@ -86,7 +108,10 @@ public class ClearingJournalService {
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
// Clearing raises its own voucher below, naming the customer account the item belongs to;
// the journal command must not raise a second one over the same money.
final CommandProcessingResult result = this.voucherService
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
if (result == null) {
log.warn("Clearing journal entry did not return a result ({}).", comments);
return null;
@@ -95,9 +120,77 @@ public class ClearingJournalService {
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
}
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
return glTxnId;
}
/**
* Records one clearing posting in the voucher register.
*
* Raised here rather than in each caller because CTS items, NACH movements and settlement
* summaries all post through this one path: a voucher per caller would be the same slip written
* three times, and one of them would drift.
*
* The caller's comment carries what the posting is — the item, the movement, the batch — so it
* becomes the narration on both sides. Where the caller named a customer account, the debit line
* carries that customer and account number; a settlement summary names no account and its lines
* stand on the GL names alone, which is what a summary is.
*
* A failure here does not fail the posting. The entry is already in the ledger, and clearing runs
* over batches where one unwritable slip must not take a whole settlement down with it. The
* problem is logged and the posting stands.
*/
private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
final String comments, final Long linkAccountId, final String linkAccountType) {
if (glTxnId == null) {
return;
}
try {
final Map<String, Object> debitGl = this.voucherService.glAccount(debitGlId);
final Map<String, Object> creditGl = this.voucherService.glAccount(creditGlId);
final Map<String, Object> holder = accountHolderForVoucher(linkAccountId, linkAccountType);
// The debit is the side a customer account is named against, matching the row this
// posting links to the account for reporting.
final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name");
final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no")
: (String) debitGl.get("gl_code");
this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId,
this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL,
List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments),
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
.withDescription(comments)));
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e);
}
}
/**
* The customer and account number a clearing item belongs to, for printing on a voucher.
*
* The type decides which table to read: savings and current accounts number independently, so
* the same id exists in both and looking in the wrong one would put another customer's name on
* the slip. Empty when the caller named no account, as a settlement summary does, or named one
* without saying which kind — a wrong name is worse on a voucher than no name.
*/
private Map<String, Object> accountHolderForVoucher(final Long accountId, final String accountType) {
if (accountId == null || accountType == null) {
return Map.of();
}
final String sql;
if ("SAVINGS".equalsIgnoreCase(accountType)) {
sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?";
} else if ("CURRENT".equalsIgnoreCase(accountType)) {
sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
} else {
return Map.of();
}
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
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,
@@ -46,6 +46,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -65,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -109,7 +111,7 @@ public class ClerkDisbursementRequestApiResource {
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
if (requestType == null || requestType.isBlank()
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) {
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
requestType = "DISBURSEMENT";
}
@@ -185,9 +187,13 @@ public class ClerkDisbursementRequestApiResource {
activityLog, Timestamp.from(Instant.now()), requestType
);
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a
// bank transfer both move money out of the source side, so they read as a debit.
final String voucherPrefix = "COLLECTION".equals(requestType)
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
final String voucherNo = voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, txnAmount, currentUser);
newId, txnAmount, currentUser, voucherPrefix);
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
@@ -544,7 +550,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId);
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -715,6 +721,11 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
}
// The sides of the slip, built from the entries as approved. Only reached once every
// posting above has succeeded, so the voucher describes a completed movement rather than
// one that was attempted.
addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType);
final String txnRefsStr = String.join(",", txnRefs);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
@@ -981,7 +992,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, logKh);
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
final String voucherNo = generateBtVoucherNo(logId);
final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
final JsonObject res = new JsonObject();
@@ -997,9 +1008,15 @@ public class ClerkDisbursementRequestApiResource {
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
* under concurrent inserts on the same date.
*/
private String generateBtVoucherNo(final long logId) {
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%05d", logId);
/**
* Bank-transfer settlement voucher, drawn from the same central register as every other
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
* the debited accounts, so it reads as a debit: DR/2108/0001.
*/
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
return voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1032,6 +1049,97 @@ public class ClerkDisbursementRequestApiResource {
);
}
/**
* Records the sides of an approved disbursement, collection or bank transfer on its voucher.
*
* The register covers three directions under one request type, and each puts the society's GL
* on a different side:
*
* <ul>
* <li>DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each
* account credited.</li>
* <li>COLLECTION — money into the target GL out of the listed accounts: each account debited,
* GL credited.</li>
* <li>BANK_TRANSFER — account to account with no GL involved at all; each entry already carries
* its own side, so they are used as given.</li>
* </ul>
*
* The society's side is written as a single line for the total rather than one per counterparty,
* because that is how it posts and how the slip reads.
*
* 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.
*/
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType);
final boolean isCollection = "COLLECTION".equals(requestType);
final List<VoucherLine> lines = new ArrayList<>();
BigDecimal counterpartyTotal = BigDecimal.ZERO;
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
final String accountNo = 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");
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? entry.get("glAccountId").getAsLong()
: null;
final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
? entry.get("savingsAccountId").getAsLong()
: null;
// 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
? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")
: isCollection;
final VoucherLine line;
if (isGlEntry) {
line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount)
: VoucherLine.creditGl(glId, accountName, accountNo, entryAmount);
} else {
line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount)
: VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount);
}
lines.add(line);
if (!isBankTransfer) {
counterpartyTotal = counterpartyTotal.add(entryAmount);
}
}
// The society's own side. A bank transfer has none — the money never leaves the members
// 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");
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
}
// Debits first, so the voucher prints the way a slip is read.
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
voucherService.addLines(voucherNo, lines);
} catch (final RuntimeException e) {
log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e);
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
@@ -1216,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
commandsSourceWritePlatformService.logCommandSource(cmd);
// The disbursement register raises its own voucher; suppress the journal command's own.
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
}
// ── Combined GL entry helpers ─────────────────────────────────────────────
@@ -1653,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
// The disbursement register raises its own voucher, naming every account involved; the
// journal command must not raise a second one over the same money.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
return result != null ? result.getTransactionId() : null;
}
@@ -41,6 +41,8 @@ import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@@ -55,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -91,7 +94,25 @@ public class ClerkShareWithdrawalRequestApiResource {
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
// "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 ───────────────────
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
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 PRODUCT_TYPE_SHARES = 4;
// 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;
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
@@ -187,8 +208,9 @@ public class ClerkShareWithdrawalRequestApiResource {
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
}
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
@@ -206,11 +228,11 @@ public class ClerkShareWithdrawalRequestApiResource {
where.append(" AND status = ?");
params.add(status);
}
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
return queryList(where.toString(), params, fromDate, toDate, null, offset, limit);
}
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
final int offset, final int limit) {
final String search, final int offset, final int limit) {
final StringBuilder where = new StringBuilder(baseWhere);
final List<Object> params = new ArrayList<>(baseParams);
if (fromDate != null && !fromDate.isBlank()) {
@@ -221,6 +243,13 @@ public class ClerkShareWithdrawalRequestApiResource {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
}
if (search != null && !search.isBlank()) {
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR resolution_number ILIKE ?)");
final String like = "%" + search.trim() + "%";
params.add(like);
params.add(like);
params.add(like);
}
final long total = Optional
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
@@ -320,7 +349,7 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied,
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null);
@@ -336,6 +365,14 @@ public class ClerkShareWithdrawalRequestApiResource {
: -1;
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
// Active only means approved and activated -- not paid for. Real share capital is only posted
// once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and
// approve() below hard-blocks on this same flag, so a request failing this check can never
// actually be approved regardless of what a checker decides here.
final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied"));
checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied,
collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet"));
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
: null;
@@ -585,6 +622,20 @@ public class ClerkShareWithdrawalRequestApiResource {
final Object meetingDateObj = record.get("meeting_date");
final String resolutionNumber = (String) record.get("resolution_number");
// Account creation, approval and activation post no share-capital GL of their own -- a share
// account can be Active with "approved" shares that nobody has actually paid for yet, and only
// gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see
// ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was
// never issued, so this is a hard block here, not just an advisory line in validate()'s
// checklist -- a checker approving off that checklist alone has no other backstop.
final Boolean collectionApplied = jdbcTemplate
.queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream()
.findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null);
if (!Boolean.TRUE.equals(collectionApplied)) {
throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been "
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
}
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
@@ -603,11 +654,20 @@ 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, immediately credit the redemption value
// to the member's linked savings account. Two Fineract journal entries get posted — native
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
// 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()
@@ -689,14 +749,17 @@ public class ClerkShareWithdrawalRequestApiResource {
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
* the unit price ourselves — the transaction was already priced by Fineract's own
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
* command so the transaction is fully audited and posts its own GL entry the normal way — the
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
* on that bridge account. Falls back to the legacy hardcoded
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
* column existed.
* today) and already nets out any redemption charge.
*
* <p>
* 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.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final String resolutionNumber, final Long settlementPaymentTypeId) {
@@ -735,7 +798,35 @@ public class ClerkShareWithdrawalRequestApiResource {
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return new Settlement(settlementAmount, result.getResourceId());
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));
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);
return new Settlement(settlementAmount, savingsTransactionId);
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@@ -806,6 +897,125 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
// ── Sundry pass-through helpers ──────────────────────────────────────────
/**
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
*
* <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.
*/
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
final long userId) {
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 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);
}
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
return jdbcTemplate.queryForMap("""
SELECT sa.account_no, sa.product_id, sa.currency_code, c.office_id
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id
WHERE sa.id = ?
""", shareAccountId);
}
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw apiError(409, label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
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 = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (final IncorrectResultSizeDataAccessException e) {
return null;
}
}
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
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) {
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,
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. 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 Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
JsonArray log = new JsonArray();
if (existingJson != null && !existingJson.isBlank()) {
@@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -69,6 +72,10 @@ import com.google.common.base.Splitter;
@RequiredArgsConstructor
public class ClerkWithdrawalRequestApiResource {
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
// activity JsonArray, and a field of the same name reads as that variable at a glance.
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
@@ -151,7 +158,8 @@ public class ClerkWithdrawalRequestApiResource {
@QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
@QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
@@ -163,6 +171,14 @@ public class ClerkWithdrawalRequestApiResource {
where.append(" AND DATE(submitted_at) <= ?");
baseParams.add(Date.valueOf(toDate));
}
if (search != null && !search.isBlank()) {
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR voucher_no ILIKE ? OR tok_no ILIKE ?)");
final String like = "%" + search.trim() + "%";
baseParams.add(like);
baseParams.add(like);
baseParams.add(like);
baseParams.add(like);
}
final long total = Optional.ofNullable(
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
@@ -457,6 +473,10 @@ public class ClerkWithdrawalRequestApiResource {
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
}
// 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);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
);
@@ -594,6 +614,11 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
}
// The voucher was raised in the clerk's name at submission, before any cashier was involved --
// reattribute it to whoever is actually handing over the cash now, so it shows up in their own
// voucher register rather than only in the clerk's.
voucherService.updateCashier(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, currentUser);
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
if (denominationData != null && !denominationData.isBlank()) {
saveDisbursementDenomination(denominationData, record, currentUser);
@@ -663,6 +688,7 @@ public class ClerkWithdrawalRequestApiResource {
final String accountNo = (String) record.get("account_no");
final Object clientIdObj = record.get("client_id");
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
final String paymentTypeName = (String) record.get("payment_type_name");
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
final int count;
@@ -686,16 +712,42 @@ public class ClerkWithdrawalRequestApiResource {
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashier.getId(), cashier.getUsername()
cashier.getId(), cashier.getUsername(), paymentTypeName
);
}
}
/**
* 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.
*
* 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) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
return;
}
try {
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)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
}
}
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,
@@ -798,9 +850,9 @@ public class ClerkWithdrawalRequestApiResource {
/**
* Ensures the token number is unique for today's date among non-rejected requests, checking
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
* {@code excludeId} is set during resubmit to skip the record being edited.
* this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} --
* all three are redeemed at the very same Transaction Entry token field, so none of the pools
* may ever collide. {@code excludeId} is set during resubmit to skip the record being edited.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder(
@@ -815,13 +867,19 @@ public class ClerkWithdrawalRequestApiResource {
final long ownCount = Optional.ofNullable(
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
).orElse(0L);
final long otherCount = Optional.ofNullable(
final long dividendCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + otherCount > 0) {
final long closureCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_clerk_closure_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + dividendCount + closureCount > 0) {
throw apiError(400,
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
}
@@ -160,10 +160,13 @@ public class ClientKycApiResource {
template.put("clientTypeName", clientTypeName);
template.put("legalFormId", legalFormId);
// Minor detection mirrors the frontend's own rule (age < 18) so the document list
// served here stays consistent with what the UI computes from the same date of birth.
// Minor detection mirrors the frontend's own rule so the document list served here stays
// consistent with what the UI computes from the same date of birth. Both sides read the
// threshold from a single named constant -- when they disagree, this endpoint wins and would
// silently serve an adult document list (mandatory PAN) to a minor.
final LocalDate dateOfBirth = client.dateOfBirth();
final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18;
final boolean isMinor = dateOfBirth != null
&& Period.between(dateOfBirth, LocalDate.now()).getYears() < ClientApiConstants.MINOR_AGE_THRESHOLD;
template.put("isMinor", isMinor);
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor));
@@ -225,7 +225,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
controlGlId, suspenseGlId);
} else {
this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount,
"CTS inward clearing pass - item #" + itemId, item.getAccountId());
"CTS inward clearing pass - item #" + itemId, item.getAccountId(), item.getAccountType());
}
markLeaf(item, true, "CTS cleared", null, userId);
@@ -738,7 +738,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
account.setHelpers(this.savingsAccountTransactionSummaryWrapper, this.savingsHelper);
if (item.isHoldPlaced() && item.getHoldAmount() != null) {
account.releaseOnHoldAmount(item.getHoldAmount());
account.releaseOnHoldAmount(item.getHoldAmount(), SavingsAccount.HOLD_CATEGORY_CLEARING);
}
final Office office = account.office();
final Money amountMoney = Money.of(account.getCurrency(), amount);
@@ -770,7 +770,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
}
if (isSavings(item)) {
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
account.releaseOnHoldAmount(item.getHoldAmount());
account.releaseOnHoldAmount(item.getHoldAmount(), SavingsAccount.HOLD_CATEGORY_CLEARING);
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
} else {
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
@@ -284,7 +284,10 @@ public class InwardClearingValidationService {
if (account == null) {
return;
}
account.holdAmount(amount);
// A presented-but-unsettled cheque is exactly what "Clearing Hold" means -- money in the
// clearing pipeline, not a staff-placed lien -- so it shares the bucket with a pending
// cheque/DD deposit rather than the generic manual Cash Hold.
account.holdAmount(amount, SavingsAccount.HOLD_CATEGORY_CLEARING);
this.savingsAccountRepository.saveAndFlush(account);
} else {
final CurrentAccount account = this.currentAccountRepository.findById(item.getAccountId()).orElse(null);
@@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
@@ -45,6 +44,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -52,90 +52,73 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker workflow for a member to claim an unclaimed dividend (see
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
* that was never credited into any savings account). A clerk (maker) records exactly how the
* member wants it split when they visit the branch: straight into an overdue loan, credited to
* their linked savings account, or handed over as cash -- any combination, any leftover amount
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
* Maker-checker workflow for a member to claim an unclaimed dividend (see {@code m_share_dividend_lock}, status
* {@code UNCLAIMED} -- a "Dividend Payable" GL liability that was never credited into any savings account). A clerk
* (maker) records the claim when the member visits the branch; a Bank Manager (checker) approves it, which draws the
* draft, or rejects it, leaving the unclaimed liability untouched. Any amount not claimed stays {@code UNCLAIMED} for a
* later visit, and is forfeited to profit if three years pass.
*
* <p>
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
* which debits whatever GL account is actually configured as that product's Fund Source -- not
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
* that same DEBIT leg is rewritten in place to Dividend Payable (see
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
* entry pair, so the transaction ends up with exactly the entries a real accountant would expect
* -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
* post through, so approval
* posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
* physical hand-over is deferred: the claim's {@code cash_status} becomes
* {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
* redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
* the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
* submit/approve/disburse token flow for ordinary savings withdrawals.
* A dividend is collected as a Demand Draft, and only as a Demand Draft. The savings-credit, cash-at-counter and
* loan-set-off routes this workflow previously offered are retired: a draft leaves a numbered instrument the member
* holds and the branch can trace, where a savings credit disappeared into a running balance and cash left no
* counterfoil at all. Claims settled through the old routes remain readable — their columns are still populated on
* those historical rows — but new claims only ever write {@code dd_apply_amount}.
*
* <p>
* The draft is raised through the ordinary Sponsor DD flow, consuming a leaflet from inventory and carrying a real DD
* number, so it reconciles, stops and reprints like any other. What differs is the funding: a DD bought at the counter
* debits the purchaser, whereas this one is funded from the society's own Dividend Payable liability, because the
* member is being paid rather than buying anything. That is what {@code payment_method = 'DIVIDEND'} marks on the issue
* request, and why no charge or GST attaches to it.
*/
@Path("/v1/clerk/dividend-claim-requests")
@Component
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
@RequiredArgsConstructor
@Slf4j
public class DividendClaimRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST";
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
/**
* Named as the purchaser on a dividend draft. The society bought the draft out of its own Dividend Payable, not the
* member — so the register shows who actually raised it rather than repeating the beneficiary's name in both
* columns, which would read as a counter sale to the member.
*/
private static final String PURCHASER_SOCIETY_DIVIDEND = "THE SOCIETY (Dividend Payout)";
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
"l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
"r.submitted_at", "r.actioned_at" };
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
@GET
@Path("/overdue-loans/{clientId}")
@Produces(MediaType.APPLICATION_JSON)
public String overdueLoans(@PathParam("clientId") final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
row.addProperty("accountNo", (String) loan.get("account_no"));
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
result.add(row);
}
return gson.toJson(result);
}
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", "r.savings_apply_amount",
"r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
"r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at",
// The Demand Draft this claim is collected as — the only settlement route for new claims.
// loan/savings/cash columns above remain for claims settled before those routes were retired.
"r.dd_apply_amount", "r.dd_beneficiary_name", "r.dd_payable_at_branch", "r.dd_payable_at_city", "r.dd_issue_request_id",
"r.dd_number" };
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
@@ -147,18 +130,13 @@ public class DividendClaimRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
// while a dividend share often works out to paise -- floor to the nearest rupee (never
// trusting the frontend to have already done so) so the denomination breakdown the
// cashier counts at disburse time can always match exactly; any leftover paise is simply
// not claimed here and stays part of the lock's balance for a future claim.
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
.setScale(0, RoundingMode.FLOOR);
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final BigDecimal ddApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("ddApplyAmount", root));
final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root));
final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root));
final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root));
// Which physical leaflet the clerk took off the shelf. Optional: a caller that has not
// chosen one gets the oldest available instead.
final Long requestedLeafletId = fromJsonHelper.extractLongNamed("leafletId", root);
if (lockId == null) {
throw apiError(400, "lockId is required");
}
@@ -171,53 +149,19 @@ public class DividendClaimRequestApiResource {
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final Object lockSavingsAccountId = lock.get("savings_account_id");
// The share account isn't required to have a linked savings account up front any more --
// if the member wants a savings credit and none is linked, the clerk supplies which
// account to credit right here.
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
: savingsAccountIdOverride;
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (resolvedSavingsAccountId == null) {
throw apiError(400,
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
}
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
resolvedSavingsAccountId);
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
}
if (ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "ddApplyAmount is required and must be greater than zero");
}
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(400,
"Amount claimed (" + ddApplyAmount + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
if (total.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
if (ddBeneficiaryName == null) {
throw apiError(400, "ddBeneficiaryName is required — a Demand Draft cannot be drawn without a payee");
}
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (loanId == null) {
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
}
final Map<String, Object> loan = jdbcTemplate
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
}
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
throw apiError(400, "Loan " + loanId + " is not Active");
}
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (tokNo == null || tokNo.isBlank()) {
throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
}
validateTokenUniqueToday(tokNo, null);
if (ddPayableAtBranch == null || ddPayableAtCity == null) {
throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft");
}
final Long existingPending = jdbcTemplate.queryForObject(
@@ -226,40 +170,129 @@ public class DividendClaimRequestApiResource {
throw apiError(409, "A claim request for this dividend is already pending review.");
}
validateNotGuarantorOfOverdueLoan(clientId);
// The leaflet is taken now rather than at approval, so the DD number exists from the moment
// the claim is raised and the draft has a place in the queue that nothing else can take.
//
// The clerk chooses which one on the leaflets screen, and that choice is honoured: the draft
// has to be drawn on the physical form in their hand, or the number written on the
// counterfoil is not the number in the books. Locked so two concurrent claims cannot take the
// same leaflet; if it was taken in the meantime the clerk is told rather than being given a
// different draft than the one they are holding.
final Map<String, Object> leaflet = requestedLeafletId != null
? jdbcTemplate
.queryForList("SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ? AND status = 'AVAILABLE' "
+ "FOR UPDATE SKIP LOCKED", requestedLeafletId)
.stream().findFirst()
.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,
"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");
final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
// loan/savings/cash apply amounts are written as zero: those routes are retired, but the
// columns remain for claims settled through them historically.
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_share_dividend_claim_request
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
(lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount,
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
status, clerk_id, clerk_name, submitted_at, dd_number)
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
RETURNING id
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
""", Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), ddNumber);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
// Raised as an ordinary PENDING issue request so the draft joins the DD Issuance Approvals
// queue with every other draft, rather than sitting in a second approvals screen of its own.
// payment_method 'DIVIDEND' is what tells that approval there is no purchaser to debit —
// the society already owes this money, so the draft is funded from Dividend Payable and the
// member pays nothing: no charge, no GST, which is why those columns are zero.
final Long issueRequestId = jdbcTemplate.queryForObject("""
INSERT INTO m_sponsor_dd_issue_request
(leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
payment_method, status, maker_id, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'PENDING', ?, ?)
RETURNING id
""", Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
ddApplyAmount, branchId, java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), currentUser.getId(),
Timestamp.from(Instant.now()));
jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET dd_issue_request_id = ? WHERE id = ?", issueRequestId, newId);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "ddNumber", ddNumber, "ddIssueRequestId", issueRequestId));
}
/**
* Ensures the token number is unique for today's date across both this table and
* {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
* Transaction Entry field, so the two pools must never collide.
* Blocks a dividend claim while the member stands as guarantor on someone else's overdue loan.
*
* A guarantee is a promise to pay if the borrower does not, and the guarantor's share capital is the security
* behind it — the same capital this dividend was earned on. Paying the dividend out by draft while the guaranteed
* loan is in arrears hands over money the society may be about to demand back from the very same member, and once
* a draft is drawn there is nothing left to recover it from.
*
* Only OVERDUE guarantees block. A guarantee on a loan being repaid on time is a contingent obligation that has
* not crystallised, and withholding a dividend against it would penalise a member for a loan nobody has defaulted
* on. The block lifts on its own the moment the borrower clears the arrears.
*
* Deliberately not a silent hold: the clerk is told which loan, whose, and how much is overdue, so they can tell
* the member standing at the counter why — and so the member can go to the borrower about it.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
final List<Object> params = new ArrayList<>();
params.add(tokNo);
if (excludeId != null) {
sql.append(" AND id <> ?");
params.add(excludeId);
private void validateNotGuarantorOfOverdueLoan(final Long clientId) {
final List<Map<String, Object>> overdueGuarantees = jdbcTemplate.queryForList("""
SELECT l.account_no,
COALESCE(b.display_name, '(unnamed borrower)') AS borrower_name,
COALESCE(SUM(ri.principal_amount + ri.interest_amount
- COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.interest_completed_derived, 0)), 0) AS overdue_amount
FROM m_guarantor g
JOIN m_loan l ON l.id = g.loan_id
LEFT JOIN m_client b ON b.id = l.client_id
JOIN m_loan_repayment_schedule ri ON ri.loan_id = l.id
WHERE g.entity_id = ?
AND g.type_enum = 1
AND g.is_active = true
AND l.loan_status_id IN (300, 303, 304, 700)
AND ri.completed_derived = false
AND ri.duedate < CURRENT_DATE
GROUP BY l.account_no, b.display_name
HAVING COALESCE(SUM(ri.principal_amount + ri.interest_amount
- COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.interest_completed_derived, 0)), 0) > 0
ORDER BY overdue_amount DESC
""", clientId);
if (overdueGuarantees.isEmpty()) {
return;
}
final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
if (total > 0) {
throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
final StringBuilder detail = new StringBuilder();
for (final Map<String, Object> guarantee : overdueGuarantees) {
if (detail.length() > 0) {
detail.append("; ");
}
detail.append("loan ").append(guarantee.get("account_no")).append(" (").append(guarantee.get("borrower_name"))
.append(") overdue ").append(guarantee.get("overdue_amount"));
}
throw apiError(409, "This member is an active guarantor on an overdue loan, so their dividend cannot be paid out yet: "
+ detail + ". The dividend stays unclaimed until the arrears are cleared or the guarantee ends.");
}
private String trimToNull(final String value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private BigDecimal parseAmount(final String value) {
@@ -317,373 +350,243 @@ public class DividendClaimRequestApiResource {
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
}
// 409 rather than 403: the user has every right to approve dividend claims — just not this
// one, because they raised it. A 403 reads as "you lack permission", which sends the checker
// to an administrator to fix access that was never the problem.
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
throw apiError(409, "You raised this dividend claim, so you cannot also approve it. Two different people must handle a "
+ "payout: one to raise it, another to check it. Ask a different manager to approve this claim.");
}
final Long lockId = ((Number) record.get("lock_id")).longValue();
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream().findFirst()
.orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
}
// Re-checked at approval, not just at submission: a claim can sit pending for days, and a
// guaranteed loan can fall into arrears in that window. Approval is the point the draft is
// actually drawn, so it is the last moment the society can still hold the money back.
validateNotGuarantorOfOverdueLoan(((Number) record.get("client_id")).longValue());
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(unclaimedAmount) > 0) {
final BigDecimal ddApplyAmount = (BigDecimal) record.get("dd_apply_amount");
if (ddApplyAmount == null || ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(409, "This claim has no Demand Draft amount recorded.");
}
if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(409,
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
}
final Object claimSavingsAccountId = record.get("savings_account_id");
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
final Map<String, Object> issuedDraft = issueDemandDraftFromDividendPayable(id, lockId, record, ddApplyAmount, currencyCode, today,
currentUser);
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final Long loanId = ((Number) record.get("loan_id")).longValue();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", loanApplyAmount);
payload.addProperty("note", note);
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
}
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", savingsApplyAmount);
payload.addProperty("note", note);
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
}
final BigDecimal remainder = unclaimedAmount.subtract(total);
final BigDecimal remainder = unclaimedAmount.subtract(ddApplyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
} else {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
}
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
cash_status = ?
dd_issue_request_id = ?, dd_number = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
issuedDraft.get("issueRequestId"), issuedDraft.get("ddNumber"), id);
final JsonObject response = new JsonObject();
response.addProperty("resourceId", id);
response.addProperty("status", "APPROVED");
response.addProperty("totalDisbursed", total);
response.addProperty("totalDisbursed", ddApplyAmount);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
response.addProperty("ddNumber", (String) issuedDraft.get("ddNumber"));
response.addProperty("ddIssueRequestId", (Long) issuedDraft.get("issueRequestId"));
return gson.toJson(response);
}
// ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
/**
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
* disbursing it, instead of the manager handing over cash directly at approval time.
* Draws the Demand Draft the member is collecting their dividend as, funded by the society's Dividend Payable
* liability rather than by anything the member pays in.
*
* A DD raised at the counter debits the purchaser — their savings account, or cash they hand over — because they
* are buying the instrument. Here the society already owes the money, so the draft is funded from Dividend Payable
* and the member pays nothing: no charge, no GST. That is what payment_method 'DIVIDEND' marks, and why those
* columns are written as zero.
*
* The leaflet is taken from inventory and marked ISSUED exactly as the normal flow does, so the draft carries a
* real DD number, appears in DD reconciliation, and can be stopped or reprinted like any other.
*/
@GET
@Path("/by-token/{token}")
@Produces(MediaType.APPLICATION_JSON)
public String getByToken(@PathParam("token") final String token) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
FROM m_share_dividend_claim_request r
JOIN m_client c ON c.id = r.client_id
WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
ORDER BY r.submitted_at DESC LIMIT 1
""", token);
private Map<String, Object> issueDemandDraftFromDividendPayable(final Long claimId, final Long lockId,
final Map<String, Object> record, final BigDecimal amount, final String currencyCode, final LocalDate today,
final AppUser currentUser) {
if (rows.isEmpty()) {
throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
}
// 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()
.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");
final Map<String, Object> row = rows.get(0);
final String status = String.valueOf(row.get("status"));
final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
final Long clientId = ((Number) record.get("client_id")).longValue();
final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
if ("PENDING".equals(status)) {
throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
}
if ("REJECTED".equals(status)) {
throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
}
if (!"APPROVED".equals(status)) {
throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
}
if ("DISBURSED".equals(cashStatus)) {
throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
}
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
}
return gson.toJson(row);
}
// APPROVED outright: the manager approving this claim IS the check on it, and a second
// maker-checker round on the draft would leave a member's paid dividend in limbo.
// client_id still records WHOSE dividend this is — the entitlement belongs to them and the
// draft has to be traceable back to the member. walkin_purchaser_name names who actually
// raised it, which is the society, so the two roles stay distinguishable.
final Long issueRequestId = jdbcTemplate.queryForObject("""
INSERT INTO m_sponsor_dd_issue_request
(leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
payment_method, status, maker_id, created_date, checker_id, checker_date)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'APPROVED', ?, ?, ?, ?)
RETURNING id
""", Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"),
record.get("dd_payable_at_branch"), record.get("dd_payable_at_city"), amount, branchId, java.sql.Date.valueOf(today),
((Number) record.get("clerk_id")).longValue(), Timestamp.from(Instant.now()), currentUser.getId(),
Timestamp.from(Instant.now()));
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
// The leaflet carries its own copy of the draft's details, and the DD register reads from
// there rather than from the issue request — so setting the status alone leaves the draft
// listed with every column blank. Mirrors what the ordinary issue flow writes.
//
// issued_to_client_id is deliberately left NULL and the purchaser named as the society
// instead. On an ordinary draft the client IS the purchaser — they walked in and bought it.
// Here the society is paying the member their own dividend, so recording the member as
// purchaser would read as though they had bought the draft, and the register would show the
// same name as both purchaser and beneficiary with nothing to distinguish a
// society-initiated draft from a counter sale.
jdbcTemplate.update("""
UPDATE m_sponsor_dd_leaflet
SET status = 'ISSUED', face_value = ?, issued_to_client_id = NULL,
walkin_purchaser_name = ?, beneficiary_name = ?,
payable_at_branch = ?, payable_at_city = ?, issuing_branch_id = ?, issued_date = ?,
advice_status = 'NOT_SENT'
WHERE id = ?
""", amount, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), record.get("dd_payable_at_branch"),
record.get("dd_payable_at_city"), branchId, java.sql.Date.valueOf(today), leafletId);
/**
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
* approve time, so this step is purely a physical-cash-handover record with double-disbursement
* protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
*/
@POST
@Path("/{id}/disburse")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String disburse(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
final Map<String, Object> record = jdbcTemplate
.queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
id)
.stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
postDividendPayableToDdPayable(claimId, lockId, ddNumber, currencyCode, amount, today, currentUser);
final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
}
String denominationData = null;
BigDecimal transactionAmount = null;
if (body != null && !body.isBlank()) {
final JsonElement root = fromJsonHelper.parse(body);
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
}
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
}
// cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
// but a claim created before that fix may still carry paise -- floor here too so an older
// claim can still be disbursed in whole-rupee cash rather than being stuck forever.
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
if (disbursableAmount.compareTo(transactionAmount) != 0) {
throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
+ disbursableAmount + " (cash can only be handed over in whole rupees).");
}
final long denomTotal = denominationTotal(denominationData);
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
throw apiError(400,
"Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
}
final int updated = jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET
cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
""", currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
if (updated == 0) {
throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
}
if (denominationData != null && !denominationData.isBlank()) {
final Long clientId = ((Number) record.get("client_id")).longValue();
final Long lockId = ((Number) record.get("lock_id")).longValue();
saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
}
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
}
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
private long denominationTotal(final String denominationData) {
if (denominationData == null || denominationData.isBlank()) {
return 0;
}
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (final Exception e) {
throw apiError(400, "denominationData is not valid JSON");
}
if (denomMap == null) {
return 0;
}
long total = 0;
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) {
continue;
}
final int value;
final int count;
try {
value = Integer.parseInt(parts[1]);
count = entry.getValue().getAsInt();
} catch (final Exception e) {
continue;
}
if (count > 0) {
total += (long) value * count;
}
}
return total;
final Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("issueRequestId", issueRequestId);
result.put("ddNumber", ddNumber);
return result;
}
/**
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
* claim request's own id is used as the stable txn identifier for grouping.
* Moves the member's entitlement from Dividend Payable to the DD liability: the society still owes the money, but
* now against a numbered instrument rather than an unclaimed dividend. Cleared when the draft is presented and
* paid, through the normal DD settlement.
*
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
* and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
*/
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
final String denominationData, final AppUser cashier) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (final Exception e) {
return;
}
if (denomMap == null || denomMap.size() == 0) {
return;
}
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final int count;
try {
count = entry.getValue().getAsInt();
} catch (final Exception e) {
continue;
}
if (count <= 0) {
continue;
}
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) {
continue;
}
final String kind = parts[0];
final int value;
try {
value = Integer.parseInt(parts[1]);
} catch (final Exception e) {
continue;
}
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
Long.class, value, kind);
if (ids.isEmpty()) {
continue;
}
final Long denomId = ids.get(0);
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
""", claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
}
}
/**
* Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
* no real customer payment instrument -- resolves the system-seeded "Dividend Claim
* Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
* would break on any tenant seeded differently.
*/
private Long resolveDividendClaimPaymentTypeId() {
final Long paymentTypeId = jdbcTemplate
.queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long dividendPaidAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_PAID)
.stream().findFirst().orElse(null);
final Long ddPayableAccountId = jdbcTemplate
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
.findFirst().orElse(null);
if (paymentTypeId == null) {
throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
}
return paymentTypeId;
}
/**
* The loan repayment / savings deposit command just posted its own native GL entries assuming
* the money came out of whichever account is that product's actual Fund Source (not always
* literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
* own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
* a second manual entry pair (which would leave that fund-source account appearing twice, once
* debited then immediately credited back -- a confusing "wash" no real accountant would post),
* this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
* ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
* Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
* updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
* the two ids is non-null per call.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null) {
return;
}
if (loanTransactionId != null) {
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
if (savingsTransactionId != null) {
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
}
/**
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
* member's hand.
*/
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long cashAtVaultAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
if (payableDividendsAccountId == null || ddPayableAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
final String description = "Dividend claim #" + claimRequestId + " disbursed";
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
final String transactionId = "DIVDD" + claimId;
// 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.
final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
+ claimId + " (lock #" + lockId + ")";
if (dividendPaidAccountId == null) {
// Dividend Paid not mapped: post straight across, exactly as before this account existed.
// A society that has not configured it still gets a correct payout, just without the
// separate "how much has been claimed" measure.
postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
currentUser);
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
return;
}
// Routed through Dividend Paid rather than posted straight across, so the society can read
// off what has been claimed and what is still unclaimed as two separate balances instead of
// only the net of Dividend Payable.
//
// Two pairs, in the order the money moves, each with its own description — reading Dividend
// Paid's ledger has to show which line settled the member's entitlement and which raised the
// draft, and one shared description makes the two indistinguishable.
postJournalEntryPair(payableDividendsAccountId, dividendPaidAccountId, amount, currencyCode, transactionId, entryDate,
description + " (member's entitlement settled)", currentUser);
// The obligation does not end here: the society still owes the money until the draft is
// presented and paid, which is why this moves to Sponsor DD Payable rather than to cash.
// Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
description + " (draft raised)", currentUser);
// The voucher names the two accounts the payout actually moves between, not the three the
// ledger touches. Dividend Paid nets to nil across the pair above: it is there to measure
// how much has been claimed, and putting it on the slip would show the member a middle
// account that holds none of their money.
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
}
/**
* Records the payout in the voucher register: Dividend Payable debited, the DD liability credited.
*
* A failure here does not fail the claim. The dividend is already posted and the draft already
* raised, and refusing an approved payout because its slip could not be written would hold back
* a member's money over paperwork. The claim stands, and the register shows a gap.
*/
private void raiseDividendVoucher(final Long claimId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
final String description, final AppUser currentUser) {
try {
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,
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)));
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for dividend claim {}", claimId, e);
}
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
.orElse(Map.of());
}
/**
* Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction
* the money travelled and the ledger reads in order.
*/
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
final AppUser currentUser) {
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -691,13 +594,12 @@ public class DividendClaimRequestApiResource {
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
jdbcTemplate.update(insertSql, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount,
description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
jdbcTemplate.update(insertSql, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount,
description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
@POST
@Path("/{id}/reject")
@@ -742,6 +644,7 @@ public class DividendClaimRequestApiResource {
private Map<String, Object> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
status, clerk_id, clerk_name
FROM m_share_dividend_claim_request WHERE id = ?
""", id);
@@ -751,6 +654,28 @@ public class DividendClaimRequestApiResource {
return rows.get(0);
}
/**
* The financial year of the declaration a lock came from — walking lock to per-member detail to declaration.
*
* Returns "unknown" rather than throwing if that chain is broken: a missing label must never stop a member being
* paid their dividend.
*/
private String financialYearOfLock(final Long lockId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT d.dividend_period_end_date
FROM m_share_dividend_lock l
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
WHERE l.id = ?
""", lockId);
if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
return "unknown";
}
final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
@@ -26,6 +26,7 @@ import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@@ -51,11 +52,10 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
* progress, full 3-year lock history, and members whose unclaimed dividend (see
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
* to be forfeited to profit after 3 years.
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout progress, full 3-year
* lock history, and members whose unclaimed dividend (see {@code m_share_dividend_lock}) sits alongside an overdue loan
* installment so the manager can proactively apply it towards that EMI instead of waiting for the member to claim it or
* for it to be forfeited to profit after 3 years.
*/
@Path("/v1/clerk/dividend-dashboard")
@Component
@@ -112,8 +112,7 @@ public class DividendDashboardApiResource {
final JsonObject result = new JsonObject();
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
result.addProperty("pendingApprovalDeclarations",
((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("pendingApprovalDeclarations", ((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
@@ -129,12 +128,70 @@ public class DividendDashboardApiResource {
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard/declaration/{id}/report-header ──────────
/**
* The heading of a Share Dividend Distribution Report: what rate the board resolved, what it came to, and when it
* was paid.
*
* The per-member breakdown screen already lists who gets what, but a distribution report is not just the list — it
* has to state the rate and the payment date, because that is what an AGM resolution and an auditor both ask for.
* Those two facts live on the declaration rather than on any member row, so they are fetched once here rather than
* repeated on every row of the member query.
*
* The rate is read as declared, never re-derived from amount over capital: a share movement back-dated into a
* closed period would silently change a rate the board actually resolved.
*/
@GET
@Path("/declaration/{dividendId}/report-header")
@Produces(MediaType.APPLICATION_JSON)
public String declarationReportHeader(@PathParam("dividendId") final Long dividendId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT d.id, p.name AS product_name, d.amount, d.rate_percentage, d.status,
d.dividend_period_start_date, d.dividend_period_end_date,
d.created_date, d.lastmodified_date,
COUNT(sad.id) FILTER (WHERE sad.amount > 0) AS paid_members,
COUNT(sad.id) FILTER (WHERE sad.amount = 0) AS unpaid_members,
COALESCE(SUM(sad.amount), 0) AS allocated_amount
FROM m_share_product_dividend_pay_out d
JOIN m_share_product p ON p.id = d.product_id
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = d.id
WHERE d.id = ?
GROUP BY d.id, p.name
""", dividendId);
if (rows.isEmpty()) {
throw new NotFoundException("Dividend declaration not found: " + dividendId);
}
final Map<String, Object> row = rows.get(0);
final boolean approved = ((Number) row.get("status")).intValue() == DIVIDEND_PAYOUT_STATUS_APPROVED;
final JsonObject out = new JsonObject();
out.addProperty("dividendId", ((Number) row.get("id")).longValue());
out.addProperty("productName", (String) row.get("product_name"));
final Object periodEnd = row.get("dividend_period_end_date");
out.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
out.addProperty("periodEndDate", String.valueOf(periodEnd));
out.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd)));
out.addProperty("ratePercentage", (BigDecimal) row.get("rate_percentage"));
out.addProperty("declaredAmount", (BigDecimal) row.get("amount"));
out.addProperty("allocatedAmount", (BigDecimal) row.get("allocated_amount"));
out.addProperty("paidMembers", ((Number) row.get("paid_members")).longValue());
out.addProperty("unpaidMembers", ((Number) row.get("unpaid_members")).longValue());
out.addProperty("status", approved ? "APPROVED" : "PENDING_APPROVAL");
out.addProperty("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date")));
// The payment date IS the approval date: approval is what makes a member's entitlement
// claimable, so nothing is payable before it and it is the date the report must quote.
out.addProperty("paymentDate",
approved && row.get("lastmodified_date") != null ? String.valueOf(row.get("lastmodified_date")) : null);
return gson.toJson(out);
}
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
/**
* Every dividend ever declared (across every share product), so a manager tracking multiple
* dividends at once can see -- per declaration -- how many members it covers and how far its
* payout has actually progressed, not just whether it was approved.
* Every dividend ever declared (across every share product), so a manager tracking multiple dividends at once can
* see -- per declaration -- how many members it covers and how far its payout has actually progressed, not just
* whether it was approved.
*/
@GET
@Path("/declarations")
@@ -179,25 +236,116 @@ public class DividendDashboardApiResource {
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
/**
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
* profit). Optional {@code status} query param filters to one.
* One member's dividend record across every financial year — what they were entitled to, what they collected, what
* they let lapse, and on which draft.
*
* The lock history answers "what is outstanding right now"; this answers "what has this member ever been paid",
* which is the question at the counter when someone asks about a dividend from years ago. Forfeited years are
* included deliberately: a member asking why they never received a dividend needs to be told it lapsed, not to be
* shown nothing.
*
* Searched by name or share account number, because a member at the counter has a passbook rather than a client id.
*/
@GET
@Path("/member-history")
@Produces(MediaType.APPLICATION_JSON)
public String memberHistory(@QueryParam("search") final String search) {
final String term = search == null ? "" : search.trim();
if (term.length() < 2) {
return gson.toJson(new JsonArray());
}
final String like = "%" + term.toLowerCase() + "%";
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id AS client_id, c.display_name AS client_name, sha.account_no AS share_account_no,
p.name AS product_name, d.dividend_period_start_date, d.dividend_period_end_date,
sad.amount AS entitled_amount,
l.id AS lock_id, l.status AS lock_status, l.amount AS lock_amount,
l.locked_date, l.release_due_date, ln.account_no AS applied_loan_account_no,
cr.status AS claim_status, cr.dd_number, cr.actioned_at
FROM m_share_account_dividend_details sad
JOIN m_share_account sha ON sha.id = sad.account_id
JOIN m_client c ON c.id = sha.client_id
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
JOIN m_share_product p ON p.id = d.product_id
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id
LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED')
WHERE sad.amount > 0
AND (LOWER(c.display_name) LIKE ? OR LOWER(COALESCE(sha.account_no, '')) LIKE ?)
ORDER BY c.display_name, d.dividend_period_end_date DESC
""", like, like);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
entry.addProperty("clientName", (String) row.get("client_name"));
entry.addProperty("shareAccountNo", (String) row.get("share_account_no"));
entry.addProperty("productName", (String) row.get("product_name"));
final Object periodEnd = row.get("dividend_period_end_date");
entry.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
entry.addProperty("periodEndDate", String.valueOf(periodEnd));
entry.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd)));
entry.addProperty("entitledAmount", (BigDecimal) row.get("entitled_amount"));
final Object lockId = row.get("lock_id");
entry.addProperty("lockId", lockId == null ? null : ((Number) lockId).longValue());
// No lock row at all means the entitlement was below the payable minimum and was swept
// with the rounding residue — never anybody's money to collect.
entry.addProperty("status", lockId == null ? "BELOW_MINIMUM" : (String) row.get("lock_status"));
entry.addProperty("amount", (BigDecimal) row.get("lock_amount"));
entry.addProperty("lockedDate", row.get("locked_date") == null ? null : String.valueOf(row.get("locked_date")));
entry.addProperty("releaseDueDate", row.get("release_due_date") == null ? null : String.valueOf(row.get("release_due_date")));
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
entry.addProperty("claimStatus", (String) row.get("claim_status"));
entry.addProperty("ddNumber", (String) row.get("dd_number"));
entry.addProperty("claimedOn", row.get("actioned_at") == null ? null : String.valueOf(row.get("actioned_at")));
result.add(entry);
}
return gson.toJson(result);
}
/**
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a
* member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3
* years passed unclaimed, swept to the bank's profit). Optional {@code status} query param filters to one.
*/
@GET
@Path("/locks")
@Produces(MediaType.APPLICATION_JSON)
public String locks(@QueryParam("status") final String status) {
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
// Which declaration each lock came from. Once a member has more than one unclaimed dividend —
// this year's plus a prior year they never collected — amount and locked date alone cannot
// tell them apart, so the clerk has no way to say which year is being paid out. The join
// walks lock -> per-member detail -> declaration -> product to name the period and product.
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no, "
+ "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name, " //
// The share account the dividend was earned on. A clerk searching for a member at the
// counter has the passbook in front of them, which carries the account number and not
// the display name as the system spells it — so the number has to be searchable too.
+ "sha.account_no AS share_account_no, " //
// The draft this dividend is being paid out as, and how far along it is. Once a claim
// exists the clerk needs the DD number rather than another Claim button — it is what
// goes on the counterfoil and what the member asks about when they come back.
+ "cr.status AS claim_status, cr.dd_number AS claim_dd_number " //
+ "FROM m_share_dividend_lock l " //
+ "JOIN m_client c ON c.id = l.client_id " //
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
+ "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " //
+ "LEFT JOIN m_share_account sha ON sha.id = sad.account_id " //
+ "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " //
+ "LEFT JOIN m_share_product p ON p.id = d.product_id " //
// The live claim only: a rejected one leaves the dividend claimable again, so it must
// not keep showing a DD number the member will never receive.
+ "LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') " //
+ (filterByStatus ? "WHERE l.status = ? " : "") //
+ "ORDER BY l.locked_date DESC";
// Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that
// needs attention, and it is the one a member should be paid out of first.
+ "ORDER BY d.dividend_period_end_date DESC NULLS LAST, l.locked_date DESC";
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
: jdbcTemplate.queryForList(sql);
@@ -207,6 +355,9 @@ public class DividendDashboardApiResource {
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
entry.addProperty("clientName", (String) row.get("client_name"));
entry.addProperty("shareAccountNo", (String) row.get("share_account_no"));
entry.addProperty("claimStatus", (String) row.get("claim_status"));
entry.addProperty("claimDdNumber", (String) row.get("claim_dd_number"));
final Object savingsAccountId = row.get("savings_account_id");
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
@@ -217,6 +368,17 @@ public class DividendDashboardApiResource {
final Object appliedLoanId = row.get("applied_loan_id");
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
final Object declarationId = row.get("declaration_id");
entry.addProperty("declarationId", declarationId == null ? null : ((Number) declarationId).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
final Object periodStart = row.get("dividend_period_start_date");
final Object periodEnd = row.get("dividend_period_end_date");
entry.addProperty("periodStartDate", periodStart == null ? null : periodStart.toString());
entry.addProperty("periodEndDate", periodEnd == null ? null : periodEnd.toString());
// The financial year the dividend was earned in, taken from the period end so a
// declaration is labelled by the year it closes — "FY 2026-27", the way a member and an
// auditor both refer to it.
entry.addProperty("financialYear", periodEnd == null ? null : financialYearOf(periodEnd.toString()));
result.add(entry);
}
return gson.toJson(result);
@@ -229,9 +391,14 @@ public class DividendDashboardApiResource {
public String list() {
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
l.locked_date, l.release_due_date
l.locked_date, l.release_due_date,
-- The share account the dividend was earned on, so a clerk holding the
-- member's passbook can find the row by its number rather than by name.
sha.account_no AS share_account_no
FROM m_share_dividend_lock l
JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
LEFT JOIN m_share_account sha ON sha.id = sad.account_id
WHERE l.status = 'UNCLAIMED'
AND EXISTS (
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
@@ -248,6 +415,7 @@ public class DividendDashboardApiResource {
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
row.addProperty("clientId", clientId);
row.addProperty("clientName", (String) lock.get("client_name"));
row.addProperty("shareAccountNo", (String) lock.get("share_account_no"));
final Object savingsAccountId = lock.get("savings_account_id");
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
@@ -260,17 +428,19 @@ public class DividendDashboardApiResource {
}
private JsonArray overdueLoansFor(final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final List<Map<String, Object>> loans = jdbcTemplate.queryForList(
"""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""",
clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
@@ -285,14 +455,13 @@ public class DividendDashboardApiResource {
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
/**
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
* and file a claim. Since the dividend was never credited into any savings account (it's a
* pure "Dividend Payable" GL liability until claimed -- see
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
* native and correct) and then redirects the debit leg it assumed the cash came from to the
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue loan EMI (see
* {@code list()} above) and applies it without waiting for the member to visit and file a claim. Since the dividend
* was never credited into any savings account (it's a pure "Dividend Payable" GL liability until claimed -- see
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to make: this posts a
* native loan repayment (so the loan's own schedule/state stays fully native and correct) and then redirects the
* debit leg it assumed the cash came from to the Dividend Payable liability instead -- identical mechanic to a
* claim's loan leg.
*/
@POST
@Path("/{lockId}/apply-to-loan")
@@ -341,15 +510,14 @@ public class DividendDashboardApiResource {
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", applyAmount);
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId).withJson(payload.toString())
.build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
} else {
jdbcTemplate.update("""
UPDATE m_share_dividend_lock
@@ -366,16 +534,14 @@ public class DividendDashboardApiResource {
}
/**
* The loan repayment just posted above assumed the money came out of whichever account is
* this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
* Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
* -- it didn't really. Rather than reverse that with a second manual entry pair (which would
* leave that fund-source account appearing twice, once debited then immediately credited back
* -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
* account in place to Dividend Payable, so the transaction ends up with exactly the entries a
* real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
* nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
* Payable financial activity.
* The loan repayment just posted above assumed the money came out of whichever account is this loan product's
* actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product
* debits its own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with a second
* manual entry pair (which would leave that fund-source account appearing twice, once debited then immediately
* credited back -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's account
* in place to Dividend Payable, so the transaction ends up with exactly the entries a real accountant would expect:
* Dividend Payable debited, Loan Portfolio/Income credited, nothing else touched. Skipped (loan still updated) if
* the bank hasn't mapped the Dividend Payable financial activity.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
@@ -389,6 +555,16 @@ public class DividendDashboardApiResource {
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
/**
* The Indian financial year an ISO date falls in, as "2026-27". April starts the year, so a declaration ending
* 31 March 2027 is FY 2026-27 — the label a member and an auditor would both use for it.
*/
private String financialYearOf(final String isoDate) {
final LocalDate date = LocalDate.parse(isoDate);
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
@@ -217,6 +217,8 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler;
@@ -232,6 +234,7 @@ import org.springframework.transaction.annotation.Transactional;
public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService {
private final PlatformSecurityContext context;
private final VoucherService voucherService;
private final LoanTransactionValidator loanTransactionValidator;
private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer;
private final LoanRepositoryWrapper loanRepositoryWrapper;
@@ -521,6 +524,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
// Money leaving the society to the borrower: the Loan Portfolio account is debited, because
// the loan is now an asset owed back, and the fund the money came out of is credited.
// Skipped for an account transfer, where the disbursement lands in another account rather
// than being paid out and that account's own posting carries the voucher.
if (!isAccountTransfer && disbursalTransactionId != null) {
final String voucherNo = raiseLoanVoucher(loan, disbursalTransactionId, loan.getDisbursedAmount(), true);
if (voucherNo != null) {
changes.put("voucherNo", voucherNo);
}
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loan.getId()) //
@@ -1073,6 +1087,56 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.build();
}
/**
* Records a loan disbursement or repayment in the voucher register, and returns its number.
*
* The borrower's side is the product's Loan Portfolio account, because that is what the balance
* posts against; the line still names the borrower and their loan account number, so the slip
* reads as theirs. The other side is the till.
*
* A disbursement debits the portfolio — the society now holds a receivable — and credits the
* cash paid out. A repayment reverses that.
*
* A failure here does not fail the transaction. The disbursement or repayment is already posted
* and the borrower's balance already moved; taking that back over a slip would undo a correct
* transaction for a paperwork reason. The problem is logged and the transaction stands.
*/
private String raiseLoanVoucher(final Loan loan, final Long transactionId, final BigDecimal amount, final boolean isDisbursement) {
// A register further out may have already raised a voucher naming the instrument this
// movement belongs to; its voucher describes it better than a bare loan posting would.
if (!this.voucherService.shouldRaiseVoucher()) {
return null;
}
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);
// 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);
final List<VoucherLine> lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
return this.voucherService.createVoucherWithLines(
isDisbursement ? VoucherService.TYPE_DISBURSEMENT : VoucherService.TYPE_REPAYMENT,
VoucherService.REF_LOAN_TRANSACTION, transactionId, currentUser,
isDisbursement ? VoucherService.PREFIX_DEBIT : VoucherService.PREFIX_CREDIT, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for loan transaction {}", transactionId, e);
return null;
}
}
private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) {
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText);
@@ -1118,6 +1182,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
loan = loanTransaction.getLoan();
this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction);
// Money coming back from the borrower: cash is debited and the Loan Portfolio account is
// credited, reducing what the loan still carries as owed.
final String repaymentVoucherNo = raiseLoanVoucher(loan, loanTransaction.getId(), transactionAmount, false);
if (repaymentVoucherNo != null) {
changes.put("voucherNo", repaymentVoucherNo);
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
.withLoanId(loan.getId()) //
.withEntityId(loanTransaction.getId()) //
@@ -176,6 +176,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.apache.fineract.portfolio.rate.service.RateAssembler;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
@@ -413,7 +414,7 @@ public class LoanAccountConfiguration {
@Bean
@ConditionalOnMissingBean(LoanWritePlatformService.class)
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context,
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, VoucherService voucherService,
LoanTransactionValidator loanTransactionValidator,
LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper,
LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository,
@@ -446,7 +447,8 @@ public class LoanAccountConfiguration {
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper,
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
LoanTransactionService loanTransactionService) {
return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer,
return new LoanWritePlatformServiceJpaRepositoryImpl(context, voucherService, loanTransactionValidator,
loanUpdateCommandFromApiJsonDeserializer,
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService,
holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService,
@@ -338,10 +338,10 @@ public class NachWritePlatformServiceImpl implements NachWritePlatformService {
}
if (txn.isCredit()) {
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId,
amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId());
amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType());
} else {
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId,
amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId());
amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType());
}
}
@@ -0,0 +1,186 @@
/**
* 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.orgsetup.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.sql.Timestamp;
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.serialization.FromJsonHelper;
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;
/**
* The operational profile behind a branch — code, address, contact, type and working hours — kept as
* a side table on m_office (see migration 0511) rather than added to the core office entity, so this
* stays independent of the stock office create/edit flow. Every office already exists via the
* standard Offices screen; this only adds the real-world fields on top for the offices that have them
* set. An office with no profile row yet is a valid, ordinary state — not an error.
*/
@Path("/v1/branchprofile")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BranchProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String list(@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission("BRANCHPROFILE");
final String where = officeId != null ? "WHERE o.id = ?" : "";
final List<Map<String, Object>> rows = officeId != null
? jdbcTemplate.queryForList(baseSql() + where + " ORDER BY o.hierarchy", officeId)
: jdbcTemplate.queryForList(baseSql() + " ORDER BY o.hierarchy");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toResult(row));
}
if (officeId != null) {
if (out.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
return gson.toJson(out.get(0));
}
return gson.toJson(out);
}
@PUT
@Path("{officeId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("officeId") final Long officeId, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE");
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?",
Integer.class, officeId);
if (officeExists == null || officeExists == 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
final var root = this.fromJsonHelper.parse(body);
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
final String address = this.fromJsonHelper.extractStringNamed("address", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root);
final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root);
final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root);
final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root);
final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root);
final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root);
if (branchCode != null && !branchCode.isBlank()) {
final Integer dupe = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
branchCode, officeId);
if (dupe != null && dupe > 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
"Branch code '" + branchCode + "' is already used by another branch.");
}
}
final AppUser currentUser = this.context.authenticatedUser();
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ?
WHERE office_id = ?
""", branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId);
} else {
jdbcTemplate.update("""
INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()));
}
return list(officeId);
}
private String baseSql() {
return """
SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date,
p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email,
p.working_hours_start, p.working_hours_end, p.working_days,
p.ifsc_code, p.micr_code, p.latitude, p.longitude,
p.updated_by_username, p.updated_at
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
""";
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("officeId", row.get("office_id"));
out.put("officeName", row.get("office_name"));
out.put("hierarchy", row.get("hierarchy"));
out.put("parentId", row.get("parent_id"));
final Object opening = row.get("opening_date");
out.put("openingDate", opening == null ? null : opening.toString());
out.put("branchCode", row.get("branch_code"));
out.put("branchType", row.get("branch_type"));
out.put("address", row.get("address"));
out.put("contactPhone", row.get("contact_phone"));
out.put("contactEmail", row.get("contact_email"));
out.put("workingHoursStart", row.get("working_hours_start"));
out.put("workingHoursEnd", row.get("working_hours_end"));
out.put("workingDays", row.get("working_days"));
out.put("ifscCode", row.get("ifsc_code"));
out.put("micrCode", row.get("micr_code"));
out.put("latitude", row.get("latitude"));
out.put("longitude", row.get("longitude"));
out.put("updatedByUsername", row.get("updated_by_username"));
final Object updatedAt = row.get("updated_at");
out.put("updatedAt", updatedAt == null ? null : updatedAt.toString());
return out;
}
}
@@ -0,0 +1,274 @@
/**
* 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.orgsetup.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
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;
/**
* The institution's own "Bank Master" — identity and classification, regulatory details, address
* (registered, with optional head-office/communication overrides), contact, the institution-level
* banking identifier (SWIFT/BIC — IFSC/MICR are per-branch and live on {@link BranchProfileApiResource}
* instead), document header names and terms text, the authorised representative, and audit fields. A
* singleton (always id=1): unlike offices there is only ever one bank running this tenant.
*
* Deliberately does NOT store Business Date, Financial Year, Time Zone, Working Days or Holiday
* Calendar — those already exist as their own configuration elsewhere in the system, and copying
* them here would just create a second, driftable value for the same fact. The currency shown is
* likewise derived live from {@code m_organisation_currency} rather than stored.
*/
@Path("/v1/institutionprofile")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class InstitutionProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String get() {
this.context.authenticatedUser().validateHasReadPermission("INSTITUTIONPROFILE");
ensureRowExists();
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT bank_name, legal_name, short_name, bank_code, institution_code, bank_type,
ownership_type, parent_institution_name,
registration_number, license_number, established_date, status, country,
regulatory_authority, registration_date, license_status, license_expiry_date,
swift_bic,
address_line1, address_line2, city, district, state, pincode,
head_office_same_as_registered, head_office_address,
communication_same_as_registered, communication_address,
contact_phone, contact_email, customer_care_number, grievance_email, website,
pan, tan, gstin,
statement_header_name, passbook_header_name, receipt_header_name, terms_and_conditions,
authorized_person_name, authorized_person_designation, authorized_person_contact,
authorized_person_email,
created_by_username, created_at, activation_date, deactivation_date,
updated_by_username, updated_at
FROM m_institution_profile WHERE id = 1
""");
return gson.toJson(toResult(row));
}
@PUT
@Consumes({ MediaType.APPLICATION_JSON })
public String update(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE");
ensureRowExists();
final var root = this.fromJsonHelper.parse(body);
final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root);
final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root);
final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root);
final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root);
final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root);
final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root);
final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root);
final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root);
final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root);
final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root);
final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root));
final String status = this.fromJsonHelper.extractStringNamed("status", root);
final String country = this.fromJsonHelper.extractStringNamed("country", root);
final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root);
final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root));
final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root);
final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root));
final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root);
final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root);
final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root);
final String city = this.fromJsonHelper.extractStringNamed("city", root);
final String district = this.fromJsonHelper.extractStringNamed("district", root);
final String state = this.fromJsonHelper.extractStringNamed("state", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE;
final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root);
final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE;
final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root);
final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root);
final String website = this.fromJsonHelper.extractStringNamed("website", root);
final String pan = this.fromJsonHelper.extractStringNamed("pan", root);
final String tan = this.fromJsonHelper.extractStringNamed("tan", root);
final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root);
final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root);
final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root);
final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root);
final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root);
final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root);
final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation",
root);
final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root);
final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root);
// A status flip is stamped automatically the first time it happens, rather than asking the
// user to also remember to set a date — the previous row's own status is what "the first
// time" is judged against.
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
String.class);
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
final LocalDate today = LocalDate.now();
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
UPDATE m_institution_profile
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
swift_bic = ?,
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
head_office_same_as_registered = ?, head_office_address = ?,
communication_same_as_registered = ?, communication_address = ?,
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
website = ?, pan = ?, tan = ?, gstin = ?,
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
terms_and_conditions = ?,
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
authorized_person_email = ?,
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
updated_by_username = ?, updated_at = ?
WHERE id = 1
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country,
regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic,
addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress,
communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail,
website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions,
authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail,
turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(),
Timestamp.from(Instant.now()));
return get();
}
private void ensureRowExists() {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1",
Integer.class);
if (count == null || count == 0) {
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
INSERT INTO m_institution_profile (id, status, created_by_username, created_at)
VALUES (1, 'ACTIVE', ?, ?)
""", currentUser.getUsername(), Timestamp.from(Instant.now()));
}
}
/** The tenant's one enabled currency (see the INR-only enforcement migration) — read-only here. */
private String resolveCurrencyCode() {
final List<String> codes = jdbcTemplate.queryForList(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
return codes.isEmpty() ? null : codes.get(0);
}
private LocalDate parseDate(final String raw) {
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
}
private Date toSqlDate(final LocalDate d) {
return d == null ? null : Date.valueOf(d);
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("bankName", row.get("bank_name"));
out.put("legalName", row.get("legal_name"));
out.put("shortName", row.get("short_name"));
out.put("bankCode", row.get("bank_code"));
out.put("institutionCode", row.get("institution_code"));
out.put("bankType", row.get("bank_type"));
out.put("ownershipType", row.get("ownership_type"));
out.put("parentInstitutionName", row.get("parent_institution_name"));
out.put("registrationNumber", row.get("registration_number"));
out.put("licenseNumber", row.get("license_number"));
out.put("establishedDate", str(row.get("established_date")));
out.put("status", row.get("status"));
out.put("country", row.get("country"));
out.put("currencyCode", resolveCurrencyCode());
out.put("regulatoryAuthority", row.get("regulatory_authority"));
out.put("registrationDate", str(row.get("registration_date")));
out.put("licenseStatus", row.get("license_status"));
out.put("licenseExpiryDate", str(row.get("license_expiry_date")));
out.put("swiftBic", row.get("swift_bic"));
out.put("addressLine1", row.get("address_line1"));
out.put("addressLine2", row.get("address_line2"));
out.put("city", row.get("city"));
out.put("district", row.get("district"));
out.put("state", row.get("state"));
out.put("pincode", row.get("pincode"));
out.put("headOfficeSameAsRegistered", row.get("head_office_same_as_registered"));
out.put("headOfficeAddress", row.get("head_office_address"));
out.put("communicationSameAsRegistered", row.get("communication_same_as_registered"));
out.put("communicationAddress", row.get("communication_address"));
out.put("contactPhone", row.get("contact_phone"));
out.put("contactEmail", row.get("contact_email"));
out.put("customerCareNumber", row.get("customer_care_number"));
out.put("grievanceEmail", row.get("grievance_email"));
out.put("website", row.get("website"));
out.put("pan", row.get("pan"));
out.put("tan", row.get("tan"));
out.put("gstin", row.get("gstin"));
out.put("statementHeaderName", row.get("statement_header_name"));
out.put("passbookHeaderName", row.get("passbook_header_name"));
out.put("receiptHeaderName", row.get("receipt_header_name"));
out.put("termsAndConditions", row.get("terms_and_conditions"));
out.put("authorizedPersonName", row.get("authorized_person_name"));
out.put("authorizedPersonDesignation", row.get("authorized_person_designation"));
out.put("authorizedPersonContact", row.get("authorized_person_contact"));
out.put("authorizedPersonEmail", row.get("authorized_person_email"));
out.put("createdByUsername", row.get("created_by_username"));
out.put("createdAt", str(row.get("created_at")));
out.put("activationDate", str(row.get("activation_date")));
out.put("deactivationDate", str(row.get("deactivation_date")));
out.put("updatedByUsername", row.get("updated_by_username"));
out.put("updatedAt", str(row.get("updated_at")));
return out;
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
}
@@ -0,0 +1,94 @@
/**
* 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.savings.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData;
import org.apache.fineract.portfolio.savings.service.FacilityChargeMappingReadPlatformService;
import org.springframework.stereotype.Component;
/**
* Admin screen backing API for {@code m_facility_charge_mapping}: which {@link org.apache.fineract.portfolio.charge.domain.Charge}
* is applied when a named account facility (ATM_FACILITY, UPI_FACILITY, ...) is enabled on a savings/current
* account. The 16 facility rows themselves are fixed and auto-seeded by FacilityChargeSeederService at startup,
* so this resource only ever reads them and updates which charge one maps to -- there is no create/delete.
*/
@Path("/v1/facilitychargemappings")
@Component
@Tag(name = "Facility Charge Mappings", description = """
Which charge is applied when a named account facility (ATM Facility, UPI Facility, Cheque Book, ...) \
is enabled on a savings or current account. Enabling a facility whose mapping has no charge configured \
is rejected -- configure the mapping here first.""")
@RequiredArgsConstructor
public class FacilityChargeMappingsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "FACILITYCHARGEMAPPING";
private final PlatformSecurityContext context;
private final FacilityChargeMappingReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Facility Charge Mappings", description = "Example Requests: facilitychargemappings")
public List<FacilityChargeMappingData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
return readPlatformService.retrieveAll();
}
@GET
@Path("template")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve Facility Charge Mapping Template", description = "Charge options available for mapping to a facility")
public FacilityChargeMappingData retrieveTemplate() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
return readPlatformService.retrieveTemplate();
}
@PUT
@Path("{mappingId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Facility Charge Mapping", description = "Changes which charge is applied when the facility is enabled")
public CommandProcessingResult update(@PathParam("mappingId") @Parameter(description = "mappingId") final Long mappingId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateFacilityChargeMapping(mappingId)
.withJson(apiRequestBodyAsJson).build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -81,6 +81,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositAccountInterestCalculationService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
@@ -107,6 +108,7 @@ public class FixedDepositAccountsApiResource {
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
private final SqlValidator sqlValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
@GET
@Path("template")
@@ -245,6 +247,28 @@ public class FixedDepositAccountsApiResource {
final FixedDepositAccountData accountTemplate = populateTemplateAndAssociations(accountId, account, staffInSelectedOfficeOnly,
chargeStatus, uriInfo, mandatoryResponseParameters);
accountTemplate.setActivationCharge(getActivationCharge(accountId));
// FD Special customers/staff need to see exactly how much interest is projected each month across
// the whole term, not just a flat rate/12 approximation -- computed on demand from the real
// interest engine (FixedDepositAccount#projectInterestSchedule), side-effect-free, so this GET can
// safely recompute it on every request. SINGLE/DOUBLE FDs never get this field.
if ("SPECIAL".equalsIgnoreCase(account.getFdType())) {
final java.util.List<java.util.Map<String, Object>> schedule = this.depositAccountWritePlatformService
.retrieveProjectedInterestSchedule(accountId, DepositAccountType.FIXED_DEPOSIT);
accountTemplate.setProjectedInterestSchedule(schedule);
// Computed once here -- the single source of truth for "what does the customer end up with
// overall" -- rather than the frontend re-summing the schedule array itself, the same way
// maturityAmount itself is always a backend-computed field, never a client-side derivation.
BigDecimal totalInterest = BigDecimal.ZERO;
for (final java.util.Map<String, Object> period : schedule) {
final Object amount = period.get("interestAmount");
if (amount instanceof BigDecimal) {
totalInterest = totalInterest.add((BigDecimal) amount);
}
}
final BigDecimal principal = accountTemplate.getDepositAmount();
final BigDecimal totalValue = principal != null ? principal.add(totalInterest) : totalInterest;
accountTemplate.setProjectedInterestTotals(totalInterest, totalValue);
}
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters(),
mandatoryResponseParameters);
return this.toApiJsonSerializer.serialize(settings, accountTemplate,
@@ -440,6 +464,9 @@ public class FixedDepositAccountsApiResource {
} else if (is(commandParam, "prematureClose")) {
final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "payment")) {
final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "calculatePrematureAmount")) {
final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper);
@@ -453,7 +480,7 @@ public class FixedDepositAccountsApiResource {
if (result == null) {
throw new UnrecognizedQueryParamException("command", commandParam,
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
"postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" });
}
return this.toApiJsonSerializer.serialize(result);
@@ -489,6 +516,8 @@ public class FixedDepositAccountsApiResource {
DepositAccountData account = null;
if (is(commandParam, "close")) {
account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
} else if (is(commandParam, "payment")) {
account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
}
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
@@ -0,0 +1,135 @@
/**
* 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.savings.dailyinterest;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
*
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
* be retried without recomputing the accrual, and an accrual can be rebuilt after a backdated
* transaction without disturbing what has already been paid.
*
* Each product posts in its own transaction, so one product failing does not roll back another
* that already settled. The failure is recorded against that product's run and the day continues.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class SavingsDailyInterestOrchestrator {
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
/** Accrue for the day, then post for any product whose cycle ends today. */
public void runForDate(final LocalDate businessDate) {
accrualService.accrueForDate(businessDate);
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
SELECT s.savings_product_id, s.posting_period_enum
FROM m_savings_interest_posting_schedule s
JOIN m_savings_product p ON p.id = s.savings_product_id
WHERE s.is_active = true AND p.deposit_type_enum = 100
ORDER BY s.savings_product_id
""");
for (final Map<String, Object> schedule : schedules) {
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
final Long dueCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_daily_interest
WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true
AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0
""", Long.class, productId, businessDate);
if (dueCount == null || dueCount == 0) {
updateSchedule(productId, businessDate, null);
continue;
}
try {
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
updateSchedule(productId, businessDate, "COMPLETED");
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
} catch (final RuntimeException e) {
// The product rolled back on its own; record why and let the others run.
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ?
WHERE savings_product_id = ? AND posting_date = ?
""", truncate(e.getMessage()), productId, businessDate);
updateSchedule(productId, businessDate, "FAILED");
}
}
}
/**
* Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger
* itself recorded, so the schedule and the rows can never drift apart.
*/
private void updateSchedule(final long productId, final LocalDate businessDate, final String status) {
final List<Map<String, Object>> cycle = jdbcTemplate.queryForList("""
SELECT MIN(cycle_end_date) AS next_end FROM m_savings_daily_interest
WHERE savings_product_id = ? AND balance_date = ? AND cycle_end_date >= ?
""", productId, businessDate, businessDate);
LocalDate nextPosting = null;
if (!cycle.isEmpty() && cycle.get(0).get("next_end") != null) {
nextPosting = ((java.sql.Date) cycle.get(0).get("next_end")).toLocalDate();
if (nextPosting.isEqual(businessDate) && "COMPLETED".equals(status)) {
final int period = jdbcTemplate.queryForObject(
"SELECT posting_period_enum FROM m_savings_interest_posting_schedule WHERE savings_product_id = ?",
Integer.class, productId);
nextPosting = SavingsInterestCycleCalculator.nextCycleEndDate(businessDate, period,
financialYearBeginningMonth());
}
}
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_schedule
SET next_posting_date = ?,
last_posting_date = CASE WHEN ? = 'COMPLETED' THEN ? ELSE last_posting_date END,
last_run_status = COALESCE(?, last_run_status),
last_run_at = CURRENT_TIMESTAMP, updated_on = CURRENT_TIMESTAMP
WHERE savings_product_id = ?
""", nextPosting, status, businessDate, status, productId);
}
private int financialYearBeginningMonth() {
final Integer month = jdbcTemplate.queryForObject(
"SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class);
return month == null || month < 1 || month > 12 ? 1 : month;
}
private static String truncate(final String message) {
if (message == null) {
return "unknown failure";
}
return message.length() > 1000 ? message.substring(0, 1000) : message;
}
}
@@ -0,0 +1,445 @@
/**
* 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.savings.dailyinterest;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Writes the day-by-day working behind each savings account's interest.
*
* The engine already computes interest correctly; what it does not do is leave a record. It keeps
* one derived total per account and recomputes it from the whole transaction history whenever
* anybody looks, so there is no way to ask what an account earned on a particular day, or why a
* day earned nothing. This service produces that record: one row per account per day, carrying the
* balance it was worked out on and the running total for the cycle.
*
* The interest terms - rate, posting period, compounding, calculation type, days in year - are read
* from the PRODUCT, deliberately. Fineract copies them onto each account at creation and the engine
* afterwards reads the account's copy, so the two can drift: in this tenant seventeen accounts on a
* quarterly 10% product still carry a daily 7% from whenever they were opened. Interest here is a
* property of the product a member bought, not of a stale copy, so this ledger states the product's
* terms and the whole product posts on one rhythm. The consequence is that these figures can differ
* from total_interest_earned_derived on any account whose copy has drifted; that difference is the
* point, and the reconciliation reports it rather than hiding it.
*
* The minimum balance is the exception: it is held only on the account, never on the product.
*
* Two rules the engine enforces and this has to match, or the ledger and the account will disagree:
*
* - Interest is taken on the day's CLOSING balance. EndOfDayBalance holds an opening balance too
* but never uses it in the arithmetic; opening is kept here only because it chains.
* - A day whose closing balance is under the account's minimum earns nothing at all, not a
* reduced amount. That is a cliff, and the row records it so a zero does not read as a fault.
*
* Scope is savings only - deposit_type_enum 100. Fixed and recurring deposits share
* m_savings_account and the same interest engine, but they carry maturity and lock-in rules this
* ledger does not model, so they are left alone.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class SavingsDailyInterestService {
private static final MathContext MC = new MathContext(19, RoundingMode.HALF_EVEN);
private static final int SAVINGS_DEPOSIT_TYPE = 100;
private static final int ACTIVE_STATUS = 300;
/** m_savings_account.interest_calculation_type_enum: 1 daily balance, 2 average daily balance. */
private static final class SavingsInterestCalculationType {
private SavingsInterestCalculationType() {}
static final int AVERAGE_DAILY_BALANCE = 2;
}
private final JdbcTemplate jdbcTemplate;
/**
* Writes one day's rows for every active savings account, for both savings products.
*
* Safe to run more than once for the same day: rows are keyed on (account, date) and a repeat
* updates in place rather than accruing twice. That matters because a day rewritten after a
* backdated transaction has to be able to replace what is already there.
*
* @return how many rows were written
*/
@Transactional
public int accrueForDate(final LocalDate businessDate) {
final int financialYearBeginningMonth = financialYearBeginningMonth();
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT sa.id, sa.product_id, sa.activatedon_date,
sp.nominal_annual_interest_rate AS rate,
sp.interest_posting_period_enum AS posting_period,
sp.interest_compounding_period_enum AS compounding_period,
sp.interest_calculation_type_enum AS calculation_type,
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
sa.min_balance_for_interest_calculation AS min_balance
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sp.deposit_type_enum = ?
AND sa.status_enum = ?
AND sa.activatedon_date IS NOT NULL
AND sa.activatedon_date <= ?
ORDER BY sa.product_id, sa.id
""", SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS, businessDate);
int written = 0;
for (final Map<String, Object> account : accounts) {
written += accrueOneAccount(account, businessDate, financialYearBeginningMonth) ? 1 : 0;
}
log.info("Savings daily interest: wrote {} rows for {}", written, businessDate);
return written;
}
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
final int financialYearBeginningMonth) {
final Long accountId = ((Number) account.get("id")).longValue();
final Long productId = ((Number) account.get("product_id")).longValue();
final LocalDate activatedOn = ((java.sql.Date) account.get("activatedon_date")).toLocalDate();
final BigDecimal rate = (BigDecimal) account.get("rate");
final int postingPeriod = ((Number) account.get("posting_period")).intValue();
final Number compounding = (Number) account.get("compounding_period");
final Number calcType = (Number) account.get("calculation_type");
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
// The previous row is the only thing that carries the chain forward. Its absence means
// this is the account's first day; a gap would mean a day the job never ran, and rather
// than silently re-anchoring we take the last row we do have and let the reconciliation
// report the break.
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
cycle_start_date, cycle_end_date, is_posting_day
FROM m_savings_daily_interest
WHERE savings_account_id = ? AND balance_date < ?
ORDER BY balance_date DESC LIMIT 1
""", accountId, businessDate);
final BigDecimal openingBalance;
final BigDecimal carriedInterest;
final int cycleNumber;
final LocalDate cycleStart;
final LocalDate cycleEnd;
if (previous.isEmpty()) {
openingBalance = BigDecimal.ZERO;
carriedInterest = BigDecimal.ZERO;
cycleNumber = 1;
// Cycle 1 is a stub: it starts when the account was activated, not on the grid.
cycleStart = activatedOn;
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth);
} else {
final Map<String, Object> prev = previous.get(0);
openingBalance = (BigDecimal) prev.get("closing_balance");
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
if (prevWasPosting) {
// The cycle closed yesterday: the sum went to the member and starts again at zero.
carriedInterest = BigDecimal.ZERO;
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
cycleStart = prevCycleEnd.plusDays(1);
cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod,
financialYearBeginningMonth);
} else {
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
cycleEnd = prevCycleEnd;
}
}
final BigDecimal movement = dayMovement(accountId, businessDate);
final BigDecimal closingBalance = openingBalance.add(movement);
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against
// whichever balance the account actually earns on, which is not always today's closing.
final boolean averageDailyBalance = calcType != null
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
final BigDecimal dailyInterest;
final BigDecimal cumulative;
final boolean belowMinimumForInterest;
if (averageDailyBalance) {
// Average daily balance is a statement about the whole period, not about one day:
// the engine averages every closing balance in the cycle and applies the rate once
// across all of the days at once. So what this works out is the cycle's running
// total, and the day's own interest is whatever that total moved by since yesterday.
// Early in a cycle the figure is provisional - a later deposit shifts the average and
// restates it - which is inherent to the method, not an artefact of writing it down.
final int daysSoFar = (int) (businessDate.toEpochDay() - cycleStart.toEpochDay()) + 1;
final BigDecimal averageBalance = averageBalanceForCycle(accountId, cycleStart, businessDate,
closingBalance);
belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
&& averageBalance.compareTo(minBalance) < 0;
if (belowMinimumForInterest || averageBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
cumulative = carriedInterest;
dailyInterest = BigDecimal.ZERO;
} else {
cumulative = averageBalance.multiply(rate, MC)
.divide(BigDecimal.valueOf(100L), MC)
.divide(BigDecimal.valueOf(daysInYear), MC)
.multiply(BigDecimal.valueOf(daysSoFar), MC);
dailyInterest = cumulative.subtract(carriedInterest);
}
} else {
belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
&& closingBalance.compareTo(minBalance) < 0;
if (belowMinimumForInterest || closingBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
dailyInterest = BigDecimal.ZERO;
} else {
dailyInterest = closingBalance.multiply(rate, MC)
.divide(BigDecimal.valueOf(100L), MC)
.divide(BigDecimal.valueOf(daysInYear), MC);
}
cumulative = carriedInterest.add(dailyInterest);
}
final boolean isPostingDay = !businessDate.isBefore(cycleEnd);
jdbcTemplate.update("""
INSERT INTO m_savings_daily_interest
(savings_product_id, savings_account_id, balance_date, opening_balance, closing_balance,
daily_interest, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date,
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum,
posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET
opening_balance = EXCLUDED.opening_balance,
-- A day whose interest has already been paid keeps the balance it was left
-- with. Re-accruing that day recomputes the closing balance from
-- transactions alone, which does not include this ledger's own posting, and
-- writing it back would erase the credit from the chain: the next day would
-- open lower than the member's real balance and they would stop earning on
-- interest they have been given.
closing_balance = CASE
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount
ELSE EXCLUDED.closing_balance END,
daily_interest = EXCLUDED.daily_interest,
-- Likewise the total that was actually paid: once money has moved on the
-- strength of this figure, recomputing it would leave the ledger disagreeing
-- with the member's passbook. A posted day is settled.
cycle_cumulative_interest = CASE
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
THEN m_savings_daily_interest.cycle_cumulative_interest
ELSE EXCLUDED.cycle_cumulative_interest END,
cycle_number = EXCLUDED.cycle_number,
cycle_start_date = EXCLUDED.cycle_start_date,
cycle_end_date = EXCLUDED.cycle_end_date,
posting_date = EXCLUDED.posting_date,
is_posting_day = EXCLUDED.is_posting_day,
below_min_balance = EXCLUDED.below_min_balance,
-- The terms are re-stamped too. A row rewritten under different product terms
-- must not keep the old rate beside a figure the new one produced, or the row
-- stops explaining itself. Days already posted keep their money above; this
-- only restates what the money was worked out from.
interest_rate = EXCLUDED.interest_rate,
days_in_year = EXCLUDED.days_in_year,
compounding_period_enum = EXCLUDED.compounding_period_enum,
posting_period_enum = EXCLUDED.posting_period_enum,
calculation_type_enum = EXCLUDED.calculation_type_enum,
min_balance_for_interest = EXCLUDED.min_balance_for_interest,
last_recomputed_on = CURRENT_TIMESTAMP
""", productId, accountId, businessDate, openingBalance, closingBalance, dailyInterest, cumulative,
cycleNumber, cycleStart, cycleEnd, cycleEnd, isPostingDay, rate, daysInYear,
compounding == null ? null : compounding.intValue(), postingPeriod,
calcType == null ? null : calcType.intValue(), minBalance, belowMinimumForInterest);
return true;
}
/**
* The mean of the cycle's daily closing balances, today included.
*
* Rows already written for this cycle carry the earlier days; today's closing is not among
* them yet, so it is added by the caller. A day the ledger never wrote would pull the mean
* off, which is the other reason the chain check matters.
*/
private BigDecimal averageBalanceForCycle(final Long accountId, final LocalDate cycleStart,
final LocalDate businessDate, final BigDecimal todaysClosing) {
final BigDecimal earlierSum = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(closing_balance), 0) FROM m_savings_daily_interest
WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ?
""", BigDecimal.class, accountId, cycleStart, businessDate);
final Long earlierDays = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_daily_interest
WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ?
""", Long.class, accountId, cycleStart, businessDate);
final long days = (earlierDays == null ? 0L : earlierDays) + 1L;
final BigDecimal total = (earlierSum == null ? BigDecimal.ZERO : earlierSum).add(todaysClosing);
return total.divide(BigDecimal.valueOf(days), MC);
}
/**
* The day's net movement, excluding interest postings.
*
* Interest postings are left out on purpose. The posting is this ledger's own output, and
* folding it back in here would let it accrue interest on itself a second time. It is added to
* the closing balance by the posting run, on the posting day, once.
*/
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
final BigDecimal movement = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE
WHEN t.transaction_type_enum IN (1, 8) THEN t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
ELSE 0 END), 0)
FROM m_savings_account_transaction t
WHERE t.savings_account_id = ?
AND t.transaction_date = ?
AND t.is_reversed = false
""", BigDecimal.class, accountId, businessDate);
return movement == null ? BigDecimal.ZERO : movement;
}
/**
* 365, 360, or the actual length of the year. The column holds the enum value, which for the
* two fixed options is the number itself; ACTUAL is 1 and means the calendar year.
*/
private int daysInYear(final Object raw, final LocalDate businessDate) {
if (raw == null) {
return 365;
}
final int value = ((Number) raw).intValue();
if (value == 360 || value == 365) {
return value;
}
return businessDate.lengthOfYear();
}
private int financialYearBeginningMonth() {
final Integer month = jdbcTemplate.queryForObject(
"SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class);
return month == null || month < 1 || month > 12 ? 1 : month;
}
/**
* Rebuilds the ledger for one account from a date forward, for when a backdated transaction
* changes what earlier days should have earned.
*
* Recomputing in place rather than writing correction rows: the ledger's job is to say what an
* account earned, and after a backdated deposit the honest answer is the new figure. The
* last_recomputed_on stamp is what records that the row was revisited.
*/
@Transactional
public int recomputeAccountFrom(final long accountId, final LocalDate fromDate, final LocalDate toDate) {
final int financialYearBeginningMonth = financialYearBeginningMonth();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.product_id, sa.activatedon_date,
sp.nominal_annual_interest_rate AS rate,
sp.interest_posting_period_enum AS posting_period,
sp.interest_compounding_period_enum AS compounding_period,
sp.interest_calculation_type_enum AS calculation_type,
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
sa.min_balance_for_interest_calculation AS min_balance
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", accountId);
if (rows.isEmpty()) {
return 0;
}
final Map<String, Object> account = rows.get(0);
int rewritten = 0;
for (LocalDate day = fromDate; !day.isAfter(toDate); day = day.plusDays(1)) {
accrueOneAccount(account, day, financialYearBeginningMonth);
rewritten++;
}
log.info("Savings daily interest: recomputed {} days for account {} from {}", rewritten, accountId, fromDate);
return rewritten;
}
/**
* Backfills every active savings account from activation up to the given date.
*
* Existing accounts have months of history the ledger knows nothing about; without this the
* table starts blank and the first cycle it sees is a partial one that will not reconcile
* against the account's own totals.
*/
@Transactional
public int backfillAll(final LocalDate upToDate) {
final List<Long> accountIds = jdbcTemplate.queryForList("""
SELECT sa.id FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sp.deposit_type_enum = ? AND sa.status_enum = ?
AND sa.activatedon_date IS NOT NULL
ORDER BY sa.id
""", Long.class, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS);
int total = 0;
for (final Long accountId : accountIds) {
final java.sql.Date activated = jdbcTemplate.queryForObject(
"SELECT activatedon_date FROM m_savings_account WHERE id = ?", java.sql.Date.class, accountId);
if (activated == null) {
continue;
}
total += recomputeAccountFrom(accountId, activated.toLocalDate(), upToDate);
}
log.info("Savings daily interest: backfilled {} rows across {} accounts", total, accountIds.size());
return total;
}
/**
* The two checks worth running: that no day's opening lost touch with the day before it, and
* that the open cycle still agrees with what the account itself reports as unposted.
*/
public List<String> reconcile() {
final List<String> problems = new ArrayList<>();
jdbcTemplate.queryForList("""
SELECT curr.savings_account_id, curr.balance_date, curr.opening_balance, prev.closing_balance
FROM m_savings_daily_interest curr
JOIN m_savings_daily_interest prev
ON prev.savings_account_id = curr.savings_account_id
AND prev.balance_date = curr.balance_date - INTERVAL '1 day'
WHERE curr.opening_balance <> prev.closing_balance
ORDER BY curr.savings_account_id, curr.balance_date
""").forEach(row -> problems.add(String.format(
"chain break: account %s on %s opens at %s but previous day closed at %s",
row.get("savings_account_id"), row.get("balance_date"), row.get("opening_balance"),
row.get("closing_balance"))));
jdbcTemplate.queryForList("""
SELECT d.savings_account_id, d.cycle_cumulative_interest AS ledger,
COALESCE(sa.total_interest_earned_derived, 0) - COALESCE(sa.total_interest_posted_derived, 0) AS account
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
WHERE d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
WHERE savings_account_id = d.savings_account_id)
AND ABS(d.cycle_cumulative_interest
- (COALESCE(sa.total_interest_earned_derived, 0)
- COALESCE(sa.total_interest_posted_derived, 0))) > 0.01
""").forEach(row -> problems.add(String.format(
"unposted mismatch: account %s ledger says %s, account says %s", row.get("savings_account_id"),
row.get("ledger"), row.get("account"))));
return problems;
}
}
@@ -0,0 +1,146 @@
/**
* 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.savings.dailyinterest;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
/**
* Where a posting cycle ends, for a given posting period.
*
* This mirrors SavingsHelper.determineInterestPostingPeriodEndDateFrom, which is private to the
* engine and works on the engine's own period objects. The daily ledger needs the same boundaries
* to label its rows, so the rule is restated here rather than reached into.
*
* The boundaries are calendar dates anchored on the financial year start, not anniversaries of the
* account: an account opened on 24 August under a quarterly product does not post on 24 November,
* it posts at the next quarter end on the grid. The consequence is that the first cycle of any
* account is a stub - activation to the first boundary - and every cycle after it is a full period.
*
* One deliberate difference from the engine. Fineract adds a day to the period end and posts on the
* morning after ("interest posting always occurs on next day after the period end date"), unless
* the savings-interest-posting-current-period-end configuration is on, in which case it posts on
* the period end itself. This ledger always uses the period end date, matching that configuration
* being enabled, which is the behaviour this feature was asked for. If the configuration is left
* off, the engine's own posting transaction will be dated one day later than the ledger's
* posting_date, and the reconciliation will show it - the amounts still agree, only the date moves.
*/
public final class SavingsInterestCycleCalculator {
private SavingsInterestCycleCalculator() {}
// m_savings_product.interest_posting_period_enum / SavingsPostingInterestPeriodType
public static final int DAILY = 1;
public static final int MONTHLY = 4;
public static final int QUARTERLY = 5;
public static final int BIANNUAL = 6;
public static final int ANNUAL = 7;
/**
* The last day of the cycle that {@code within} falls in - the day interest is posted.
*
* @param within
* any day inside the cycle
* @param postingPeriodEnum
* the account's posting period
* @param financialYearBeginningMonth
* 1-12, from the financial-year-beginning-month configuration
*/
public static LocalDate cycleEndDate(final LocalDate within, final int postingPeriodEnum,
final int financialYearBeginningMonth) {
switch (postingPeriodEnum) {
case DAILY:
return within;
case MONTHLY:
return within.with(TemporalAdjusters.lastDayOfMonth());
case QUARTERLY:
return periodEndOnGrid(within, financialYearBeginningMonth, 3);
case BIANNUAL:
return periodEndOnGrid(within, financialYearBeginningMonth, 6);
case ANNUAL:
return periodEndOnGrid(within, financialYearBeginningMonth, 12);
default:
throw new IllegalArgumentException("Unsupported savings interest posting period: " + postingPeriodEnum);
}
}
/**
* The first day of the cycle that {@code within} falls in, ignoring activation. Callers hold
* the account's start date and take the later of the two, which is what makes cycle 1 a stub.
*/
public static LocalDate cycleStartDate(final LocalDate within, final int postingPeriodEnum,
final int financialYearBeginningMonth) {
if (postingPeriodEnum == DAILY) {
return within;
}
final LocalDate previousEnd = cycleEndDate(within, postingPeriodEnum, financialYearBeginningMonth);
// The cycle containing `within` starts the day after the previous cycle ended.
return cycleEndDate(previousEnd.minusMonths(monthsIn(postingPeriodEnum)), postingPeriodEnum,
financialYearBeginningMonth).plusDays(1);
}
/** The cycle after the one ending on {@code cycleEnd}. */
public static LocalDate nextCycleEndDate(final LocalDate cycleEnd, final int postingPeriodEnum,
final int financialYearBeginningMonth) {
return cycleEndDate(cycleEnd.plusDays(1), postingPeriodEnum, financialYearBeginningMonth);
}
private static int monthsIn(final int postingPeriodEnum) {
switch (postingPeriodEnum) {
case MONTHLY:
return 1;
case QUARTERLY:
return 3;
case BIANNUAL:
return 6;
case ANNUAL:
return 12;
default:
return 0;
}
}
/**
* Walks the fixed grid of period ends starting at the financial year month and returns the
* first one on or after {@code within}.
*
* The grid is built from the financial year start rather than from January, so a year
* beginning in April gives quarter ends of 30 Jun, 30 Sep, 31 Dec, 31 Mar - the same dates
* the engine produces for the same configuration.
*/
private static LocalDate periodEndOnGrid(final LocalDate within, final int financialYearBeginningMonth,
final int monthsPerPeriod) {
// Last day of the month before the financial year starts, in the year `within` falls in.
LocalDate gridAnchor = within.withDayOfMonth(1).withMonth(financialYearBeginningMonth).minusDays(1);
if (gridAnchor.isAfter(within)) {
gridAnchor = gridAnchor.minusYears(1);
}
LocalDate candidate = gridAnchor;
// At most 12/monthsPerPeriod steps land on or past `within`; the extra step guards the
// case where the anchor itself is the answer.
for (int i = 0; i <= 12 / monthsPerPeriod; i++) {
if (!candidate.isBefore(within)) {
return candidate;
}
candidate = candidate.plusDays(1).plusMonths(monthsPerPeriod).minusDays(1)
.with(TemporalAdjusters.lastDayOfMonth());
}
return candidate;
}
}
@@ -0,0 +1,300 @@
/**
* 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.savings.dailyinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* Pays a savings product's accrued interest to its members, pooled through two control accounts.
*
* Stock Fineract posts interest one member at a time - debit Interest on Savings, credit Savings
* Control, per account - and its posting job spreads that work across a thread pool, each batch in
* its own transaction. That is sound double entry, but it has no seam a failure can be caught on:
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
* holds the figure that would say so.
*
* This posts the product's total first, into Savings Interest Posting Control, moves it to Savings
* Interest Posted Control, and only then pays it out to members. The whole product settles inside
* one transaction, and both control accounts have to read zero before it is allowed to commit. A
* failure rolls the product back entirely - nobody is half paid - and a run that never reached
* commit leaves a PENDING row naming the product and date for the next run to pick up.
*
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
* ends a few paise adrift and that money is simply gone. Here the residual is worked out and given
* to the largest holding - the account least distorted by it - so the pool closes exactly and the
* amount that moved is written down in the run record.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class SavingsPooledInterestPostingService {
/** Two decimals: what the member is actually credited. */
private static final int MONEY_SCALE = 2;
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
private static final String GL_POSTING_CONTROL = "200304";
private static final String GL_POSTED_CONTROL = "200305";
/** m_savings_account_transaction.transaction_type_enum */
private static final int INTEREST_POSTING = 3;
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
private static final int ENTITY_TYPE_SAVINGS = 2;
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
private final JdbcTemplate jdbcTemplate;
/**
* Posts one product's interest for one posting date.
*
* REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other
* product's completed run down with it.
*
* @return the amount distributed, or zero if there was nothing to do
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
final Long completed = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_run
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
""", Long.class, productId, postingDate);
if (completed != null && completed > 0) {
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
return BigDecimal.ZERO;
}
// The accounts due today, with what they accrued. Anything already carrying a posting
// transaction is skipped, so a resumed run cannot pay somebody a second time.
// A savings account has no office of its own: it belongs to a client, or to a group when
// there is no client, and the office is theirs. The journal entries have to be raised
// against that office or they land in the wrong branch's books.
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
d.cycle_cumulative_interest AS gross_interest,
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
sa.currency_digits
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
ORDER BY d.savings_account_id
""", productId, postingDate);
if (due.isEmpty()) {
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
return BigDecimal.ZERO;
}
final long runId = openRun(productId, postingDate, due.size());
// Round each member's share, then make the rounded shares add up. The residual is the
// difference between what the pool holds and what the members would otherwise receive.
BigDecimal grossTotal = BigDecimal.ZERO;
BigDecimal roundedTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
row.put("rounded_interest", rounded);
grossTotal = grossTotal.add(gross);
roundedTotal = roundedTotal.add(rounded);
}
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal residual = pooled.subtract(roundedTotal);
if (residual.compareTo(BigDecimal.ZERO) != 0) {
// To the largest balance: the account where a paisa is proportionally smallest.
final Map<String, Object> largest = due.stream()
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
log.info("Savings interest posting: residual {} assigned to account {}", residual,
largest.get("savings_account_id"));
}
final Long postingControlGl = glAccountId(GL_POSTING_CONTROL);
final Long postedControlGl = glAccountId(GL_POSTED_CONTROL);
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
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;
// Stage 1: the product's whole interest bill, recognised and pooled.
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
// Stage 2: the pool moves to the account it is paid out of.
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
// Stage 3: out to the members, one at a time.
BigDecimal distributed = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final long accountId = ((Number) row.get("savings_account_id")).longValue();
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
// The member's own branch, which need not be the branch the pool was raised in.
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
newBalance);
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference,
accountId);
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId);
jdbcTemplate.update("""
UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
closing_balance = closing_balance + ?
WHERE id = ?
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?
WHERE id = ?
""", amount, amount, accountId);
distributed = distributed.add(amount);
}
// The whole point of the two control accounts: if this does not hold, nothing commits.
if (pooled.compareTo(distributed) != 0) {
throw new IllegalStateException(String.format(
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
productId, postingDate, pooled, distributed));
}
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
WHERE id = ?
""", pooled, distributed, residual, runId);
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
postingDate, distributed, due.size(), residual);
return distributed;
}
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_run
(savings_product_id, posting_date, account_count, status)
VALUES (?, ?, ?, 'PENDING')
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
account_count = EXCLUDED.account_count, status = 'PENDING',
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
""", productId, postingDate, accountCount);
return jdbcTemplate.queryForObject("""
SELECT id FROM m_savings_interest_posting_run
WHERE savings_product_id = ? AND posting_date = ?
""", Long.class, productId, postingDate);
}
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
final BigDecimal amount, final BigDecimal runningBalance) {
jdbcTemplate.update("""
INSERT INTO m_savings_account_transaction
(savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction,
transaction_type_enum, transaction_date, amount, running_balance_derived,
submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc)
VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", accountId, officeId, INTEREST_POSTING, date, amount, runningBalance, date, systemUserId(),
systemUserId());
return jdbcTemplate.queryForObject("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ?
ORDER BY id DESC LIMIT 1
""", Long.class, accountId, date, INTEREST_POSTING);
}
/** The member's own leg: the product's Savings Control account, credited per account. */
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
final long transactionId) {
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
}
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
}
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) {
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
savings_transaction_id, reversed, manual_entry, office_running_balance,
organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date,
created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount,
debit ? "Savings interest posting" : "Savings interest posted",
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
systemUserId(), systemUserId(), date);
}
/**
* The user these rows are attributed to. The job runs without anybody logged in, and the audit
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
*/
private Long systemUserId() {
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
return ids.isEmpty() ? 1L : ids.get(0);
}
private Long glAccountId(final String glCode) {
return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode);
}
private Long mappedGlAccount(final long productId, final int financialAccountType) {
return jdbcTemplate.queryForObject("""
SELECT gl_account_id FROM acc_product_mapping
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
LIMIT 1
""", Long.class, productId, financialAccountType);
}
}
@@ -0,0 +1,178 @@
/**
* 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.savings.dailyinterest.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Reads the savings daily interest ledger, per product and per account.
*
* The per-product listing is the view this was built for: pick a product, see its accounts and
* where each one is in its cycle. The per-account listing is the working itself - a day per row,
* the balance it was taken on, and the running total resetting to zero the day interest is paid.
*/
@Path("/v1/savingsdailyinterest")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class SavingsDailyInterestApiResource {
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsDailyInterestOrchestrator orchestrator;
private final Gson gson = new Gson();
/** The savings products carrying a ledger, with where each one is in its timetable. */
@GET
@Path("/products")
public String products() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT p.id, p.name, p.short_name, p.nominal_annual_interest_rate AS rate,
s.posting_period_enum, s.run_at_time, s.last_posting_date, s.next_posting_date,
s.last_run_status, s.is_active,
(SELECT COUNT(*) FROM m_savings_account sa
WHERE sa.product_id = p.id AND sa.status_enum = 300) AS active_accounts,
(SELECT COALESCE(SUM(d.cycle_cumulative_interest), 0)
FROM m_savings_daily_interest d
WHERE d.savings_product_id = p.id
AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
WHERE savings_product_id = p.id)) AS accrued_this_cycle
FROM m_savings_product p
LEFT JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id
WHERE p.deposit_type_enum = 100
ORDER BY p.id
""");
return gson.toJson(rows);
}
/** One product's accounts as at the latest day the ledger holds. */
@GET
@Path("/products/{productId}/accounts")
public String accountsForProduct(@PathParam("productId") final Long productId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT d.savings_account_id, sa.account_no, c.display_name AS client_name,
d.balance_date, d.opening_balance, d.closing_balance, d.daily_interest,
d.cycle_cumulative_interest, d.cycle_number, d.cycle_start_date, d.cycle_end_date,
d.below_min_balance
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
WHERE d.savings_product_id = ?
AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
WHERE savings_product_id = ?)
ORDER BY sa.account_no
""", productId, productId);
return gson.toJson(rows);
}
/** One account's day-by-day working, newest first. */
@GET
@Path("/accounts/{accountId}")
public String accountLedger(@PathParam("accountId") final Long accountId,
@QueryParam("limit") final Integer limit) {
final int rowLimit = limit == null || limit <= 0 ? 200 : Math.min(limit, 2000);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT balance_date, opening_balance, closing_balance, daily_interest,
cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date,
is_posting_day, posted_amount, savings_transaction_id, below_min_balance,
interest_rate, days_in_year, min_balance_for_interest, last_recomputed_on
FROM m_savings_daily_interest
WHERE savings_account_id = ?
ORDER BY balance_date DESC
LIMIT ?
""", accountId, rowLimit);
return gson.toJson(rows);
}
/** What each posting run pooled and paid, and whether it finished. */
@GET
@Path("/runs")
public String runs(@QueryParam("productId") final Long productId) {
final String sql = """
SELECT r.id, r.savings_product_id, p.name AS product_name, r.posting_date, r.account_count,
r.pooled_amount, r.distributed_amount, r.rounding_residual, r.status,
r.failure_reason, r.started_at, r.completed_at
FROM m_savings_interest_posting_run r
JOIN m_savings_product p ON p.id = r.savings_product_id
""" + (productId == null ? "" : " WHERE r.savings_product_id = ? ")
+ " ORDER BY r.posting_date DESC, r.savings_product_id";
final List<Map<String, Object>> rows = productId == null ? jdbcTemplate.queryForList(sql)
: jdbcTemplate.queryForList(sql, productId);
return gson.toJson(rows);
}
/**
* The chain check and the unposted-total check. An empty list is the answer you want: every
* day picks up where the last one left off, and the ledger still agrees with the accounts.
*/
@GET
@Path("/reconcile")
public String reconcile() {
final List<String> problems = accrualService.reconcile();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("clean", problems.isEmpty());
result.put("problemCount", problems.size());
result.put("problems", problems);
return gson.toJson(result);
}
/** Runs a day by hand, for a first backfill or after fixing a failed night. */
@POST
@Path("/run")
public String run(@QueryParam("date") final String dateParam) {
final LocalDate businessDate = dateParam == null || dateParam.isBlank()
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
: LocalDate.parse(dateParam);
orchestrator.runForDate(businessDate);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("ranFor", businessDate.toString());
return gson.toJson(result);
}
/** Rebuilds the ledger for every account from activation to the given date. */
@POST
@Path("/backfill")
public String backfill(@QueryParam("upTo") final String upToParam) {
final LocalDate upTo = upToParam == null || upToParam.isBlank()
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
: LocalDate.parse(upToParam);
final int rows = accrualService.backfillAll(upTo);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("rowsWritten", rows);
result.put("upTo", upTo.toString());
return gson.toJson(result);
}
}
@@ -97,6 +97,7 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -105,11 +106,14 @@ public class DepositAccountDataValidator {
private final FromJsonHelper fromApiJsonHelper;
private final DepositProductDataValidator productDataValidator;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
@Autowired
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator) {
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator,
final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
this.fromApiJsonHelper = fromApiJsonHelper;
this.productDataValidator = productDataValidator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
}
public void validateFixedDepositForSubmit(final String json) {
@@ -351,10 +355,40 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
}
@@ -493,10 +527,40 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
@@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator {
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.reinvestAmountParamName));
@Autowired
public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
@@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator {
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isTransferToSavings()) {
if (accountOnClosureType.isPartialReinvest()) {
// Partial Re-Invest needs a kept-amount and a destination account that only the Payment
// command collects, and only its handler implements. Neither the FD nor the RD closure
// handler has a branch for it, so without this it would fall through to their terminal
// "withdraw the whole balance" case and silently pay the deposit out in cash.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"partial.reinvest.not.allowed",
"Partial Re-Invest is only supported through the Payment action, not account closure");
} else if (accountOnClosureType.isTransferToSavings()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName,
@@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is
* for Close/Premature Close, so this never changes either of their existing behavior. Unlike
* {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction --
* Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own
* amount and destination-account rules.
*/
public void validatePayment(final JsonCommand command) {
final String json = command.json();
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(DepositAccountType.FIXED_DEPOSIT.resourceName());
final JsonElement element = command.parsedJson();
final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element);
baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull();
final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element);
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isWithdarwDeposit()) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage(
"withdraw.deposit.not.allowed.for.payment",
"Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option.");
}
// Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same
// destination-account rule Transfer to Savings and Partial Reinvest already need.
if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest()
|| accountOnClosureType.isReinvestPrincipal()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
}
if (accountOnClosureType.isPartialReinvest()) {
final BigDecimal reinvestAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull()
.positiveAmount();
}
}
// Validate all string payment detail fields for max length
final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element);
baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero();
final Set<String> paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName,
routingCodeParamName, receiptNumberParamName, bankNumberParamName));
for (final String paymentDetailParameterName : paymentDetailParameters) {
final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element);
baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull()
.notExceedingLengthOf(50);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
//
@@ -69,7 +69,8 @@ public class SavingsAccountTransactionDataValidator {
private final FromJsonHelper fromApiJsonHelper;
private static final Set<String> SAVINGS_ACCOUNT_HOLD_AMOUNT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(transactionDateParamName, SavingsApiConstants.dateFormatParamName, SavingsApiConstants.localeParamName,
transactionAmountParamName, lienAllowedParamName, SavingsApiConstants.reasonForBlockParamName));
transactionAmountParamName, lienAllowedParamName, SavingsApiConstants.reasonForBlockParamName,
SavingsApiConstants.holdCategoryParamName));
private final ConfigurationDomainService configurationDomainService;
public void validateTransactionWithPivotDate(final LocalDate transactionDate, final SavingsAccount savingsAccount) {
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expecte
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.linkedAccountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName;
@@ -68,6 +69,7 @@ import java.util.Locale;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
@@ -87,6 +89,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException;
import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException;
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
@@ -97,9 +100,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -123,6 +128,7 @@ public class DepositAccountAssembler {
private final ExternalIdFactory externalIdFactory;
private final SavingsAccountAssembler savingsAccountAssembler;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Autowired
public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper,
@@ -134,7 +140,8 @@ public class DepositAccountAssembler {
final RecurringDepositProductRepository recurringDepositProductRepository,
final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context,
final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory,
final SavingsAccountAssembler savingsAccountAssembler) {
final SavingsAccountAssembler savingsAccountAssembler,
final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) {
this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper;
this.clientRepository = clientRepository;
@@ -151,6 +158,7 @@ public class DepositAccountAssembler {
this.paymentDetailAssembler = paymentDetailAssembler;
this.externalIdFactory = externalIdFactory;
this.savingsAccountAssembler = savingsAccountAssembler;
this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService;
}
/**
@@ -242,6 +250,14 @@ public class DepositAccountAssembler {
}
}
// FD Special (fdType=SPECIAL) is a non-cumulative, monthly-interest-payout product: interest must
// post every month and leave the account for its linked savings account (see
// assembleAccountTermAndPreClosure below and the maturity-instruction override there), regardless
// of whatever compounding/posting period the product or the request otherwise specifies. SINGLE and
// DOUBLE FD types are untouched by this flag.
final boolean isSpecialFixedDeposit = depositAccountType.isFixedDeposit() && product instanceof FixedDepositProduct
&& "SPECIAL".equalsIgnoreCase(((FixedDepositProduct) product).getFdType());
SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null;
final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName);
if (interestPeriodTypeValue != null) {
@@ -249,6 +265,13 @@ public class DepositAccountAssembler {
} else {
interestCompoundingPeriodType = product.interestCompoundingPeriodType();
}
if (isSpecialFixedDeposit) {
// Keep compounding no coarser than the forced Monthly posting below -- compounded interest gets
// reset to zero every period anyway once transferInterestToLinkedAccount is true (see
// CompoundInterestHelper), so this is only about keeping the interest engine's own
// posting/compounding-period expectations internally consistent, not a behaviour choice.
interestCompoundingPeriodType = SavingsCompoundingInterestPeriodType.MONTHLY;
}
SavingsPostingInterestPeriodType interestPostingPeriodType = null;
final Integer interestPostingPeriodTypeValue = command.integerValueOfParameterNamed(interestPostingPeriodTypeParamName);
@@ -257,6 +280,9 @@ public class DepositAccountAssembler {
} else {
interestPostingPeriodType = product.interestPostingPeriodType();
}
if (isSpecialFixedDeposit) {
interestPostingPeriodType = SavingsPostingInterestPeriodType.MONTHLY;
}
SavingsInterestCalculationType interestCalculationType = null;
final Integer interestCalculationTypeValue = command.integerValueOfParameterNamed(interestCalculationTypeParamName);
@@ -322,6 +348,28 @@ public class DepositAccountAssembler {
accountChart = DepositAccountInterestRateChart.from(productChart);
}
// A new FD priced from its product's own chart never carries a senior-citizen premium -- that
// concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a
// specific chart, price the account from that master's currently active version instead (the same
// one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows
// its premium from day one rather than only after its first renewal. RD is unaffected: this only
// runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate
// version exists for the product's FD type, in which case the product's own chart above stands.
if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName)
&& product instanceof FixedDepositProduct) {
final String fdType = ((FixedDepositProduct) product).getFdType();
if (fdType != null) {
final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService
.retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null);
if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) {
final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates);
final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs = DepositAccountDomainServiceJpa
.baseChartSlabsOf(activeBaseRates, product.currency().getCode());
accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
}
}
}
boolean withHoldTax = product.withHoldTax();
if (command.parameterExists(withHoldTaxParamName)) {
withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName);
@@ -338,7 +386,7 @@ public class DepositAccountAssembler {
if (depositAccountType.isFixedDeposit()) {
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((FixedDepositProduct) product).depositProductTermAndPreClosure();
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
prodTermAndPreClosure);
prodTermAndPreClosure, isSpecialFixedDeposit);
FixedDepositAccount fdAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, fieldOfficer,
accountNo, externalIdFactory.create(externalId), accountType, submittedOnDate, submittedBy, interestRate,
@@ -352,7 +400,7 @@ public class DepositAccountAssembler {
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((RecurringDepositProduct) product)
.depositProductTermAndPreClosure();
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
prodTermAndPreClosure);
prodTermAndPreClosure, false);
final DepositProductRecurringDetail prodRecurringDetail = ((RecurringDepositProduct) product).depositRecurringDetail();
final DepositAccountRecurringDetail accountRecurringDetail = this.assembleAccountRecurringDetail(command,
@@ -392,7 +440,7 @@ public class DepositAccountAssembler {
}
public DepositAccountTermAndPreClosure assembleAccountTermAndPreClosure(final JsonCommand command,
final DepositProductTermAndPreClosure productTermAndPreclosure) {
final DepositProductTermAndPreClosure productTermAndPreclosure, final boolean forceMonthlyPayoutToLinkedSavings) {
final DepositPreClosureDetail productPreClosure = (productTermAndPreclosure == null) ? null
: productTermAndPreclosure.depositPreClosureDetail();
final DepositTermDetail productTerm = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositTermDetail();
@@ -407,17 +455,34 @@ public class DepositAccountAssembler {
final SavingsPeriodFrequencyType depositPeriodFrequency = SavingsPeriodFrequencyType.fromInt(depositPeriodFrequencyId);
final SavingsAccount account = null;
final LocalDate expectedFirstDepositOnDate = command.localDateValueOfParameterNamed(expectedFirstDepositOnDateParamName);
final Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName);
Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName);
// calculate maturity amount
final BigDecimal maturityAmount = null;// calculated and updated in
// account
final LocalDate maturityDate = null;// calculated and updated in account
final Integer accountOnClosureTypeId = command.integerValueOfParameterNamed(maturityInstructionIdParamName);
final DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null
DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null
? DepositAccountOnClosureType.fromInt(accountOnClosureTypeId)
: null;
final Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName);
Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName);
if (forceMonthlyPayoutToLinkedSavings) {
final Long linkedSavingsAccountId = command.longValueOfParameterNamed(linkedAccountParamName);
if (linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.special.requires.linked.savings.account",
"FD Special (Monthly Interest Payout) requires a linked savings account to be selected.");
}
// Non-cumulative payout FD: every month's interest leaves for the linked savings account
// (TransferInterestToSavingsTasklet) instead of compounding, and maturity re-uses the existing
// "Transfer to Savings" maturity-instruction path (see
// DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails) to move the
// principal out and close the account automatically, with no staff action needed.
trasferInterest = true;
transferToSavingsId = linkedSavingsAccountId;
accountOnClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS;
}
return DepositAccountTermAndPreClosure.createNew(updatedProductPreClosure, updatedProductTerm, account, depositAmount,
maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, expectedFirstDepositOnDate, accountOnClosureType,
trasferInterest, transferToSavingsId);
@@ -35,6 +35,14 @@ public interface DepositAccountDomainService {
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail);
/**
* As above, but lets the caller declare the deposit as an internal account-to-account movement rather
* than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight
* from the matured deposit and must post against the transfer-suspense GL instead of cash/vault.
*/
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer);
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
@@ -56,4 +64,7 @@ public interface DepositAccountDomainService {
Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
@@ -26,6 +27,9 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository;
import org.apache.fineract.portfolio.calendar.domain.CalendarType;
import org.apache.fineract.portfolio.calendar.service.CalendarUtils;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
@@ -80,13 +90,28 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
private final CalendarInstanceRepository calendarInstanceRepository;
private final ExternalIdFactory externalIdFactory;
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Transactional
@Override
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction,
isAccountTransfer);
}
/**
* As above, but lets the caller declare the withdrawal as an internal account-to-account movement
* rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance
* moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault.
*/
private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) {
boolean isInterestTransfer = false;
boolean isWithdrawBalance = false;
final boolean backdatedTxnsAllowedTill = false;
@@ -100,7 +125,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer);
}
@Transactional
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer) {
boolean isRegularTransaction = false;
final boolean backdatedTxnsAllowedTill = false;
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
@@ -200,16 +233,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
// Captured before the withdrawal below empties the account, so the renewal history can
// record what this term actually matured at.
final BigDecimal maturedBalance = account.getAccountBalance();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId);
FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
// Renewal is an internal movement into the new deposit, not cash paid out -- post it
// against the transfer-suspense GL rather than crediting cash/vault.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, maturedBalance, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// Reached when a user closes the account with a re-invest instruction, rather than the
// maturity job acting on a standing instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
@@ -260,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
BigDecimal reInvestAmount;
if (onClosureType.isReinvestPrincipal()) {
reInvestAmount = account.getDepositAmount();
} else {
reInvestAmount = account.getAccountBalance();
}
if (onClosureType.isReinvestPrincipalAndInterest()) {
// Renews on the SAME account, so the customer keeps one account number however many times
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
// rollover itself -- which also removes the transfer-suspense round trip the old
// close-and-reopen flow needed.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
// The active CBS base interest-rate version for this FD type: the same rate card the Base
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
// new term's slabs and its senior-citizen premium, so screen and account agree.
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
// Captured before renewInPlace overwrites the account's chart, so the closing term's full
// rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost
// -- renewInPlace mutates the same chart row every time, it does not create a new one.
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) {
// "Re-invest principal only" still closes and reopens, because only the principal carries
// forward and the interest has to leave the deposit -- which needs a payout destination that
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
@@ -276,18 +364,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction);
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
// postJournalEntries() call at the end of this method posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (onClosureType.isReinvestPrincipalAndInterest()) {
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
}
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
// Reached from the maturity job processing the account's standing maturity instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -304,6 +400,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
// if(!processMaturityInstructionOnly)
@@ -429,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
}
/**
* Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium --
* against its {@link FixedDepositRenewalHistory} row.
* <p>
* Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the
* closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive.
*/
private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots, final String currencyCode) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) {
final InterestRateChartSlabFields fields = snapshot.getFields();
if (fields.annualInterestRate() == null || fields.fromPeriod() == null) {
continue;
}
this.fixedDepositRenewalChartSlabHistoryRepository
.save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(),
fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(),
fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate()));
}
}
/**
* Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private
* and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a
* new FD account is first priced from this master, instead of duplicating the logic.
*/
static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) {
// name and from_date are NOT NULL on the embeddable. A base version missing either would abort the
// whole renewal (or account creation) with a constraint violation, so treat it as "no identity to
// apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and
// the slabs -- the part that actually prices the deposit -- still get applied.
if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) {
return null;
}
final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount
return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(),
baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount);
}
/**
* Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen
* premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live.
* <p>
* The base master bands purely by tenure, so amount ranges are left unset -- which
* {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no
* from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure.
*/
static Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabsOf(final FdInterestRateEffectiveData baseRates,
final String currencyCode) {
if (baseRates == null || baseRates.getSlabs() == null) {
return Collections.emptyList();
}
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots = new ArrayList<>();
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) {
continue;
}
final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(),
SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null,
slab.getAnnualInterestRate(), currencyCode);
snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* The senior-citizen premium from the active base rate card for a given tenure.
* <p>
* The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years
* still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or
* months/years -- because only those are exact; a days-based card is therefore still never matched against a
* months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the
* wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the
* account's existing rate untouched rather than silently dropping the depositor's premium.
*/
private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure,
final Integer tenurePeriodTypeEnum) {
if (baseRates == null || baseRates.getSlabs() == null || tenure == null) {
return null;
}
final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum);
if (tenureInBaseUnit == null) {
return null;
}
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) {
continue;
}
final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum());
if (slabFrom == null || tenureInBaseUnit < slabFrom) {
continue;
}
if (slab.getToPeriod() != null) {
final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum());
if (slabTo == null || tenureInBaseUnit > slabTo) {
continue;
}
}
final BigDecimal rate = slab.getSeniorCitizenRate();
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
return rate;
}
}
return null;
}
/**
* {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so
* two periods written in different but related units can be compared without losing precision. Null for an unknown
* unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3).
*/
private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) {
if (value == null || periodTypeEnum == null) {
return null;
}
return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) {
case DAYS, MONTHS -> value;
case WEEKS -> value * 7;
case YEARS -> value * 12;
default -> null;
};
}
/**
* Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those
* two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never
* matched against a months-based tenure rather than guessing and landing the deposit in the wrong band.
*/
private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) {
if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) {
return false;
}
return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum);
}
private boolean isDayFamily(final Integer periodTypeEnum) {
final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum);
return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS;
}
/**
* Records a renewal that happened on the SAME account, where there is no second account row to describe the new term.
* <p>
* Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number,
* principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the
* account, where they no longer exist. Every row for one FD shares that FD's single account id and account number;
* they are told apart by renewal_no, which is what makes this a one-to-many history of one account.
*
* @param closingChartName
* the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if
* unavailable
* @param closingChartSlabs
* the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived
* against the closing term's row so its whole rate card remains queryable, not only the single effective
* rate {@link FixedDepositRenewalHistory} records.
* @param closingInterestRate
* the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it --
* needed only when this term has no existing row yet (see below), since the account itself no longer
* carries it once renewInPlace has run.
* @param closingTenure
* the closing term's own deposit period, captured the same way and needed for the same reason.
* @param closingTenureFrequencyEnum
* the closing term's own deposit period frequency, captured the same way and needed for the same reason.
*/
private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal,
final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount,
final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs, final BigDecimal closingInterestRate,
final Integer closingTenure, final Integer closingTenureFrequencyEnum) {
// The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old
// close-and-reopen model before renewing in place: its rows are keyed on the original account's
// number, so keying new rows on this account's number would split the history in two and re-create
// a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's
// own number when there is no chain, which is what the read side resolves to as well.
final String fdAccountNumber = account.fdAccountNumber();
final int newCycleNo = account.renewalCycleNo();
final Long createdById = user == null ? null : user.getId();
final List<FixedDepositRenewalHistory> existingRows = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber);
FixedDepositRenewalHistory closingRow = existingRows.stream()
.filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null);
// The term that just closed has no row when this FD renews for the first time since the history
// table existed. Written now, from the figures captured before the rollover, so the history
// always begins with the term the customer originally opened -- rate and tenure included, rather
// than left blank, since the caller captured them before renewInPlace overwrote the account with
// the new term's own values.
if (closingRow == null) {
closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
}
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow);
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(),
account.depositPeriodFrequency(), renewalType, renewedOn, createdById));
}
/**
* Records the renewal that started {@code newTerm}, and closes off the previous term's history row.
* <p>
* Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event
* itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not
* recoverable from the account rows alone. This writes it down explicitly.
* <p>
* The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only
* gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no
* original.
*/
private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm,
final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType,
final AppUser user) {
final String fdAccountNumber = previousTerm.fdAccountNumber();
final Long originalAccountId = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst()
.map(FixedDepositRenewalHistory::getOriginalSavingsAccountId)
.orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null);
FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository
.findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null);
// Backfill the opening term when the chain started before this table existed, so the history
// always begins with the account the customer actually opened.
if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) {
previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(),
previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(),
previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(),
previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(),
user == null ? null : user.getId()));
}
// What the closing term actually earned. maturityAmount is passed in rather than read off the
// account because the caller has already withdrawn the balance out of it by this point, which
// would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own
// term's outcome, so a reader never has to look at the next row to find out how one term did.
final BigDecimal previousPrincipal = previousTerm.getDepositAmount();
final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null
: maturityAmount.subtract(previousPrincipal);
if (previousRow != null) {
previousRow.recordOutcome(interestEarned, maturityAmount);
previousRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(previousRow);
}
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(),
previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount,
newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(),
newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId()));
}
@Transactional
@Override
public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
@@ -474,6 +839,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
// accounted for or the postJournalEntries() call below posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.prematureClosure(user, command, changes);
@@ -484,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
* the existing manual Close command still uses today.
* <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
* before.
*/
@Transactional
@Override
public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
final JsonCommand command, final Map<String, Object> changes) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
boolean isRegularTransaction = false;
boolean isAccountTransfer = false;
final boolean isPreMatureClosure = false;
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
final MathContext mc = MathContext.DECIMAL64;
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
Long savingsTransactionId = null;
// account.isMatured() only reflects whether the overnight scheduler has already flipped this
// account's status -- not whether the customer actually showed up before or after the maturity
// date. A customer walking in ON the maturity date (or any day after) is not premature just
// because that job hasn't run yet today, so the real test is the date, not the status flag.
if (!account.isMatured()) {
if (!DateUtils.isBefore(closedDate, account.maturityDate())) {
// Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would:
// flip to Matured and post final interest at the full contracted rate, no penalty.
account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
} else {
// The three closing closure types re-check this themselves inside prematureClosure()
// below, but the two reinvest branches never call it (they keep the account open on the
// same term), so it is enforced once, up front, for every genuinely-premature Payment --
// a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via
// this action either.
if (account.isLockedOnDate(closedDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed",
"This account is still within its lock-in period and cannot be processed before it elapses.", closedDate);
}
// Same penalty-aware interest calculation prematureClosure() already relies on, so a
// genuinely early Payment settles interest exactly as a Premature Close would.
account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
} else {
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
// Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads
// getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set
// before branching. renewInPlace() clears it again for the branches that keep the account open.
account.setClosedOnDate(closedDate);
// Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now
// correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way.
final boolean alreadyMatured = account.isMatured();
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and
// would otherwise accept any date at all -- including one behind already-posted interest, which
// the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount;
if (onClosureType.isPartialReinvest()) {
renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName);
if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0
|| renewedAmount.compareTo(maturedBalance) >= 0) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
}
} else {
renewedAmount = maturedBalance;
}
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
// The kept portion never left the account -- renewInPlace() just relabels it as the new
// term's principal -- so only the remainder is an actual money movement, posted the same
// way the Transfer to Savings branch below posts its full-balance transfer.
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
}
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
return savingsTransactionId;
}
@Transactional
@Override
public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail,
@@ -555,14 +1155,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
List<SavingsAccountTransaction> transactions = savingsAccount.getTransactions();
int size = transactions.size();
for (int i = size - 1;; i--) {
SavingsAccountTransaction transaction = transactions.get(i);
if (transaction.isWithdrawal() || transaction.isWithdrawalFee()) {
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
// so that withdrawal must be added to existingTransactionIds here or the caller's own
// postJournalEntries() re-derives and re-posts it a second time. The previous implementation
// found it by walking the transaction list backwards from the end while the type stayed
// withdrawal/withdrawal-fee -- but @OneToMany collections only guarantee @OrderBy ordering on
// a fresh fetch, not after an in-memory append, so a same-day interest posting or any other
// ordering quirk could put the new withdrawal anywhere in the list and break the walk
// silently. Scanning every transaction and checking against the original snapshot is correct
// regardless of collection order.
for (final SavingsAccountTransaction transaction : savingsAccount.getTransactions()) {
if ((transaction.isWithdrawal() || transaction.isWithdrawalFee()) && !existingTransactionIds.contains(transaction.getId())) {
existingTransactionIds.add(transaction.getId());
} else {
break;
}
}
}
@@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
@@ -149,6 +150,18 @@ public class FixedDepositAccount extends SavingsAccount {
}
}
/**
* Re-forces the standing instructions that make FD Special work -- see
* {@link DepositAccountTermAndPreClosure#forceMonthlyPayoutToLinkedSavings}. Called from the modify-application
* write path after this same request's own linked-account resolution has settled, since
* {@link #modifyApplication} (via {@code accountTermAndPreClosure.update}) has no fdType awareness and would
* otherwise let a SPECIAL account's transferInterestToLinkedAccount/onAccountClosureType/transferToSavingsId
* be silently unset by that same request.
*/
public void forceSpecialFdMonthlyPayoutSettings(final Long transferToSavingsId) {
this.accountTermAndPreClosure.forceMonthlyPayoutToLinkedSavings(transferToSavingsId);
}
@Override
protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) {
boolean isPreMatureClosure = false;
@@ -249,7 +262,13 @@ public class FixedDepositAccount extends SavingsAccount {
totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned());
}
final Money depositAmount = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
final Money maturityAmount = depositAmount.plus(totalInterestPayable);
// FD Special pays each period's interest out to the linked savings account every month instead of
// compounding it into this account's own balance (see DepositAccountAssembler and
// TransferInterestToSavingsTasklet) -- so unlike a cumulative FD, this account's own balance never
// actually grows to depositAmount + totalInterestPayable by maturity; it stays at depositAmount
// throughout the term. Showing the compounded projection here would be a customer-facing figure
// that never happens. SINGLE/DOUBLE FDs are unaffected -- they still get the real compounded value.
final Money maturityAmount = "SPECIAL".equalsIgnoreCase(fdType()) ? depositAmount : depositAmount.plus(totalInterestPayable);
this.accountTermAndPreClosure.updateMaturityDetails(maturityAmount.getAmount(), maturityDate);
}
@@ -276,7 +295,9 @@ public class FixedDepositAccount extends SavingsAccount {
public LocalDate calculateMaturityDate() {
final LocalDate startDate = accountSubmittedOrActivationDate();
// The CURRENT term's start, not the account's activation date -- on a renewed FD these differ,
// and measuring the term from activation would put maturity in the past.
final LocalDate startDate = depositStartDate();
LocalDate maturityDate = null;
final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod();
switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) {
@@ -312,13 +333,19 @@ public class FixedDepositAccount extends SavingsAccount {
.fromInt(this.interestCalculationDaysInYearType);
List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
// Scoped to the current term, so a renewed FD's projection covers only the term now running --
// matching what postInterest() will actually post -- rather than replaying the whole account.
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth,
postedAsOnTransactionDates);
depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
Money periodStartingBalance = Money.zero(currency);
// On a first term the opening deposit falls inside the first posting period, so the period can
// start from zero and pick the balance up from that transaction. On a renewed term that deposit
// is BEFORE the period range and would be missed, leaving the term to earn interest on nothing,
// so the term's own principal seeds the opening balance instead.
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
: Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure);
@@ -349,6 +376,91 @@ public class FixedDepositAccount extends SavingsAccount {
return allPostingPeriods;
}
/**
* Read-only period-by-period (e.g. month-wise, when posting is Monthly) projected interest schedule across
* the account's current term -- built with the exact same {@link PostingPeriod} engine as
* {@link #calculateInterestPayable}, but deliberately NOT sharing that method's body: that one also calls
* {@code this.summary.updateFromInterestPeriodSummaries(...)}, which overwrites the account's real
* totalInterestEarned/lastInterestCalculationDate -- fine when it runs once as part of an actual save, but
* corrupting if repeated on every GET request. This variant skips that call entirely, so it is safe to
* invoke as many times as a read-service likes without ever mutating the account. Used to show FD Special
* customers/staff exactly how much interest is projected to post each month across the whole term, since
* that figure otherwise only exists internally as a discarded intermediate value.
*/
public List<PostingPeriod> projectInterestSchedule(final MathContext mc, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
List<SavingsAccountTransaction> transactions = retreiveOrderedNonInterestPostingTransactions();
if (transactions.isEmpty()) {
// Account activated but not yet funded (funding is a separate deposit step) -- project the
// schedule as if the planned deposit amount lands on day one, the same synthetic-transaction
// approach updateMaturityDateAndAmountBeforeAccountActivation uses for the pre-activation
// maturity preview. Without this every period here would show zero interest against a real,
// still-empty transaction history, while the maturity amount (forced to depositAmount for
// SPECIAL FDs) already assumes the account gets funded -- this keeps the two consistent.
final Money transactionAmountMoney = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
final SavingsAccountTransaction syntheticDeposit = SavingsAccountTransaction.deposit(null, office(), null,
this.accountSubmittedOrActivationDate(), transactionAmountMoney, null);
syntheticDeposit.setRunningBalance(transactionAmountMoney);
syntheticDeposit.updateCumulativeBalanceAndDates(this.getCurrency(), interestCalculatedUpto());
transactions = new ArrayList<>();
transactions.add(syntheticDeposit);
}
final LocalDate maturityDate = maturityDate() != null ? maturityDate() : calculateMaturityDate();
// calculateInterestPayable's own "maturityDate" parameter is always fed maturityDate.minusDays(1)
// by its one real caller (see updateMaturityDateAndAmount above) -- the raw maturity date is only
// used for the end-of-day-balance reset either side of the calculation. Missing this -1 here made
// determineInterestPostingPeriods run one day too far, pushing the final period's end date (and
// therefore its amount) past the account's actual maturity date.
final LocalDate interestCalculationUpto = maturityDate.minusDays(1);
final boolean isPreMatureClosure = false;
this.resetAccountTransactionsEndOfDayBalances(transactions, maturityDate);
try {
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType
.fromInt(this.interestPostingPeriodType);
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(this.interestCompoundingPeriodType);
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(this.interestCalculationDaysInYearType);
final List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
depositStartDate(), interestCalculationUpto, postingPeriodType, financialYearBeginningMonth,
postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
: Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType
.fromInt(this.interestCalculationType);
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, interestCalculationUpto, isPreMatureClosure);
final Collection<Long> interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId());
final boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
transactions);
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance,
savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType,
interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), interestCalculationUpto,
interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation,
isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, financialYearBeginningMonth);
periodStartingBalance = postingPeriod.closingBalance();
allPostingPeriods.add(postingPeriod);
}
this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(),
isTransferInterestToOtherAccount());
return allPostingPeriods;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
}
public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
@@ -564,7 +676,13 @@ public class FixedDepositAccount extends SavingsAccount {
interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting());
this.transactions.add(newPostingTransaction);
recalucateDailyBalanceDetails = true;
} else {
} else if (!interestAlreadyPaidOut(postingTransaction)) {
// Once a period's interest has actually been swept out to the linked savings account it is
// settled history: reversing and re-posting it here would rewrite interest the customer was
// already paid and leave this account's books disagreeing with the cash that left it. But a
// posting still sitting IN the account is fair game -- that is how a stale amount (e.g. one
// the batch engine compounded) gets corrected at maturity. Testing the individual period,
// not the account-wide transfer flag, is the distinction that matters here.
final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod);
if (correctionRequired) {
postingTransaction.reverse();
@@ -586,6 +704,57 @@ public class FixedDepositAccount extends SavingsAccount {
this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions);
}
/**
* Whether this period's posted interest has already left the account for the linked savings account.
* <p>
* The sweep withdrawal carries the same transaction date as the interest posting it pays out (see
* DepositAccountReadPlatformService#retrieveDataForInterestTransfer, which pairs them by date), and
* {@link #interestTransferWithdrawalTransactionIds()} returns those WITHDRAWAL ids -- not the posting
* ids -- so the match has to be on date, not on id.
*/
private boolean interestAlreadyPaidOut(final SavingsAccountTransaction postingTransaction) {
final Collection<Long> sweepWithdrawalIds = interestTransferWithdrawalTransactionIds();
if (sweepWithdrawalIds.isEmpty()) {
return false;
}
for (final SavingsAccountTransaction transaction : this.transactions) {
if (transaction.isNotReversed() && sweepWithdrawalIds.contains(transaction.getId())
&& DateUtils.isEqual(transaction.getTransactionDate(), postingTransaction.getTransactionDate())) {
return true;
}
}
return false;
}
/**
* Guards the base {@link SavingsAccount#postInterest} correction loop, which base
* {@code SavingsAccountDomainServiceJpa#handleWithdrawal} re-triggers after every withdrawal -- including the
* sweep withdrawal that transfers a period's interest out to the linked savings account. Without this guard,
* that recalculation reverses and reposts the just-paid-out period at a freshly-recomputed (and slightly
* different, e.g. rounding) amount, leaving the already-executed sweep withdrawal mismatched against the new
* posting.
*/
@Override
protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) {
return !interestAlreadyPaidOut(postingTransaction);
}
/**
* The interest-transfer sweep withdrawal only ever fires for a period whose interest is already posted (see
* DepositAccountReadPlatformService#retrieveDataForInterestTransfer), so there is nothing new for this
* recalculation to find. Worse, at the moment this withdrawal's own post-withdrawal recalculation would run,
* the m_account_transfer_transaction linking row that marks it as a transfer withdrawal does not exist yet
* (it is created by AccountTransfersWritePlatformServiceImpl only after both transfer legs are booked) -- so
* the exclusion in {@link #resetAccountTransactionsEndOfDayBalances} cannot yet recognise it, and the
* recalculation would wrongly treat it as a real balance-reducing transaction for its own and every later
* period, reversing and reposting already-settled interest at a corrupted amount. Skipping the recalculation
* for this specific withdrawal avoids the corruption at its source.
*/
@Override
public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) {
return !isInterestTransfer;
}
public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
@@ -686,6 +855,60 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.maturityAmount();
}
/**
* The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to
* this account's own number when it has never been renewed, so callers always get a usable FD number.
*/
public String fdAccountNumber() {
final String carried = this.accountTermAndPreClosure.getFdAccountNumber();
return carried != null ? carried : getAccountNumber();
}
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
public Integer renewalCycleNo() {
return this.accountTermAndPreClosure.getRenewalCycleNo();
}
public Integer depositPeriod() {
return this.accountTermAndPreClosure.depositPeriod();
}
public Integer depositPeriodFrequency() {
return this.accountTermAndPreClosure.depositPeriodFrequency();
}
public Long reinvestedFromSavingsId() {
return this.accountTermAndPreClosure.getReinvestedFromSavingsId();
}
/**
* The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's
* chart identity can be archived rather than lost.
*/
public String currentChartName() {
return this.chart == null ? null : this.chart.chartFields().getName();
}
/**
* The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached
* from the chart's own persistence lifecycle.
* <p>
* Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full
* rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal
* history records -- rather than being silently deleted when the new term's slabs replace it.
*/
public List<DepositAccountInterestRateChart.SlabSnapshot> currentChartSlabFields() {
return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots();
}
/**
* This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when
* the product is not a Fixed Deposit product or has no type set.
*/
public String fdType() {
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
}
private Money totalInterestPosted() {
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
@@ -715,11 +938,26 @@ public class FixedDepositAccount extends SavingsAccount {
return actualChanges;
}
/**
* The start of the term currently running, which is what the deposit's tenure and rate must be measured from.
* <p>
* For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances
* startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS
* term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the
* renewal rather than from the original activation.
*/
private LocalDate depositStartDate() {
// TODO: Support to add deposit start date which can be a date after
// account activation date.
final LocalDate depositStartDate = accountSubmittedOrActivationDate();
return depositStartDate;
final LocalDate currentTermStartDate = getStartInterestCalculationDate();
return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate();
}
/**
* True once this FD has been renewed at least once, i.e. the term now running started later than the account did.
* Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term.
*/
private boolean isRenewedTerm() {
final LocalDate termStart = getStartInterestCalculationDate();
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
}
private LocalDate interestCalculatedUpto() {
@@ -731,6 +969,24 @@ public class FixedDepositAccount extends SavingsAccount {
return uptoMaturityDate;
}
/**
* An FD that pays its interest out monthly (FD Special) carries a transfer-out WITHDRAWAL for every posted
* period, and {@link #retreiveOrderedNonInterestPostingTransactions()} strips those out before the
* interest engine sees them. The balance-segment walk has to strip the same ones: that withdrawal is dated
* the same day as the posting it pays out, so leaving it in ends the funding deposit's balance segment one
* day early while the engine's own list has nothing to cover that day -- costing the first period one
* earning day (e.g. 6 days instead of 25-Jul..31-Jul's real 7).
* <p>
* A no-op for every other account: the excluded-id lookup short-circuits to empty unless
* {@link #isTransferInterestToOtherAccount()} is true.
*/
@Override
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransaction> accountTransactionsSorted,
final LocalDate interestPostingUpToDate) {
super.resetAccountTransactionsEndOfDayBalances(excludeInterestTransferWithdrawals(accountTransactionsSorted),
interestPostingUpToDate);
}
public void validateDomainRules() {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
@@ -814,28 +1070,142 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.getTransferToSavingsAccountId();
}
/**
* Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it
* renews. No new account is opened and no money moves: the balance simply carries into the next term.
* <p>
* What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines
* interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the
* posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes
* {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it
* stands today, so a renewal is priced on current terms rather than the ones frozen at account opening.
* <p>
* The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal
* history so each term's own figures remain reportable.
*
* @param renewedAmount
* what carries into the new term: the full maturity amount, or the principal only
* @param renewedOn
* the renewal date, which becomes the new term's start
* @param currentSeniorCitizenRate
* the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to
* keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the
* renewal never turns that flag on or off.
* @param baseChartFields
* identity of the CBS base rate version being applied, or null to keep the existing chart's
* @param baseChartSlabs
* the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the
* rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls
* back to the product's own chart, so a product with no base rate version still renews on something.
*/
public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate,
final InterestRateChartFields baseChartFields, final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs) {
final SavingsProduct product = this.product;
// Confines interest posting to the new term. Set before recomputing maturity, since maturity is
// measured from the current term's start.
setStartInterestCalculationDate(renewedOn);
this.accountTermAndPreClosure.updateDepositAmount(renewedAmount);
this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Pin the chain number now that this account has renewed at least once, so a non-zero cycle number
// is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number
// if there is one and to this account's own otherwise, which is the same value every reader derives.
this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber());
// Re-price off the CURRENT product, exactly as opening a fresh deposit today would.
this.nominalAnnualInterestRate = product.nominalAnnualInterestRate();
this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue();
this.interestPostingPeriodType = product.interestPostingPeriodType().getValue();
this.interestCalculationType = product.interestCalculationType().getValue();
this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue();
this.withHoldTax = product.withHoldTax();
// Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy
// close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would
// carry the lock-in window computed at the original activation -- long expired -- so a product with
// a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only.
final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
this.lockinPeriodFrequency = product.lockinPeriodFrequency();
this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null
: renewedLockinPeriodFrequencyType.getValue();
this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null
: calculateDateAccountIsLockedUntil(renewedOn);
// The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on
// the Base Interest Rates screen, per FD type, with its own active version. That master is what
// the bank publishes and what the account-opening wizard already shows, so pricing a renewal off
// anything else would put the deposit on rates nobody chose.
if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) {
this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
} else if (this.chart != null) {
// No active base version for this FD type: fall back to the product's own chart so the
// renewal still gets current product rates rather than the ones frozen at opening.
// activeChart, not applicableChart -- the latter treats a chart with no end date as valid only
// up to today's business date, so a renewal dated in the future would find nothing.
final InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(renewedOn)
: null;
if (productChart != null) {
this.chart.refreshFrom(productChart);
}
}
// The senior-citizen premium is stored per account and takes priority over the chart in
// getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a
// senior-citizen deposit at the rate captured when the account was opened. Only overwritten when
// the caller found a currently published rate -- otherwise the existing one stands, so a missing
// rate version can never silently drop the depositor's premium.
if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) {
setSeniorCitizenInterestRate(currentSeniorCitizenRate);
}
// The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before
// this ran, so the new term has to put it back.
this.status = SavingsAccountStatusType.ACTIVE.getValue();
setClosedOnDate(null);
}
public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) {
final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount);
final SavingsProduct product = this.product;
final InterestRateChart productChart = product.applicableChart(getClosedOnDate());
// Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's
// business date, and the most recently effective chart wins when more than one covers the date.
// Falls back to applicableChart so a product whose charts have no usable from-date still resolves
// as it did before.
InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(getClosedOnDate())
: null;
if (productChart == null) {
productChart = product.applicableChart(getClosedOnDate());
}
final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart);
final AccountType accountType = AccountType.fromInt(this.accountType);
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType);
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(this.interestCompoundingPeriodType);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(this.interestCalculationDaysInYearType);
// Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's
// frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a
// renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting
// was in force when the ORIGINAL account was opened, silently ignoring every product edit made
// since (e.g. Daily->Quarterly compounding, or a lock-in added later).
final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType();
final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType();
final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType();
final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType();
final BigDecimal minRequiredOpeningBalance = null;
final BigDecimal interestRate = BigDecimal.ZERO;
// Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses
// for a brand-new account -- so a product with no chart slab covering this tenure/amount
// doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still
// re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the
// original account was opened) whenever a matching slab exists.
final BigDecimal interestRate = product.nominalAnnualInterestRate();
final Set<SavingsAccountCharge> savingsAccountCharges = null;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
final Integer lockinPeriodFrequency = product.lockinPeriodFrequency();
final boolean withdrawalFeeApplicableForTransfer = false;
final String accountNumber = null;
final boolean withHoldTax = this.withHoldTax;
final boolean withHoldTax = product.withHoldTax();
final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product,
savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType,
postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency,
@@ -845,6 +1215,30 @@ public class FixedDepositAccount extends SavingsAccount {
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
newChart.updateDepositAccountReference(reInvestedAccount);
// Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's
// reinvested FD silently drops back to the standard rate on the new term.
reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen());
reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate());
// Track which account this was reinvested from, so staff/UI can trace the two accounts to
// each other instead of the link disappearing the moment the old one closes.
newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId());
// Carry the ORIGINAL account's number forward across every renewal, so the customer keeps
// seeing one recognisable FD number even though each term is its own account/ledger row.
final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null
? this.accountTermAndPreClosure.getFdAccountNumber()
: this.getAccountNumber();
newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber);
// Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048".
newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Without this the renewed deposit has no maturity instruction, so it would mature once more and
// then sit idle rather than rolling over again as the customer asked. Done here rather than in
// the shared copy(), which Recurring Deposit renewal also uses.
newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure);
return reInvestedAccount;
}
@@ -886,7 +1280,9 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public void loadLazyCollections() {
super.loadLazyCollections();
this.chart.getId();
if (this.chart != null) {
this.chart.getId();
}
}
public BigDecimal getDepositAmount() {
@@ -902,6 +1298,52 @@ public class FixedDepositAccount extends SavingsAccount {
return SavingsAccountStatusType.MATURED.getValue().equals(this.status);
}
/**
* Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the
* Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going
* through {@link #prematureClosure}, since those branches keep the account open on the same term instead of
* closing it.
*/
public boolean isLockedOnDate(final LocalDate transactionDate) {
return isAccountLocked(transactionDate);
}
/**
* The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment
* that renews the deposit instead of closing it and so never reaches either of those methods.
* <p>
* Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three
* matter more here than they do on a closure, because a renewal turns this date into the new term's interest
* pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting
* periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent.
*/
public void validateRenewalDate(final LocalDate renewedOn) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction);
if (DateUtils.isAfterBusinessDate(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("cannot.be.a.future.date");
}
if (DateUtils.isBefore(renewedOn, getActivationDate())) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.activation.date");
}
final List<SavingsAccountTransaction> savingsAccountTransactions = retrieveListOfTransactions();
if (!savingsAccountTransactions.isEmpty()) {
final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1);
if (lastTransaction.isAfter(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.last.transaction.date");
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
public void setClosedOnDate(final LocalDate closedOnDate) {
this.closedOnDate = closedOnDate;
}
@@ -0,0 +1,99 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.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 lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's
* {@link FixedDepositRenewalHistory} row.
* <p>
* {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in
* place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not
* the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during
* term 2", not just "what rate applied".
*/
@Entity
@Table(name = "m_fd_renewal_chart_slab_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalChartSlabHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "fd_renewal_history_id", nullable = false)
private Long fdRenewalHistoryId;
@Column(name = "chart_name", length = 100)
private String chartName;
@Column(name = "description", length = 200)
private String description;
@Column(name = "period_type_enum")
private Integer periodTypeEnum;
@Column(name = "from_period")
private Integer fromPeriod;
@Column(name = "to_period")
private Integer toPeriod;
@Column(name = "amount_range_from", scale = 6, precision = 19)
private BigDecimal amountRangeFrom;
@Column(name = "amount_range_to", scale = 6, precision = 19)
private BigDecimal amountRangeTo;
@Column(name = "annual_interest_rate", scale = 6, precision = 19)
private BigDecimal annualInterestRate;
@Column(name = "currency_code", length = 3)
private String currencyCode;
/**
* The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the
* CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was
* priced off the product's own chart (which has no senior-citizen concept) or predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName,
final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod,
final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate,
final String currencyCode, final BigDecimal seniorCitizenRate) {
final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory();
slab.fdRenewalHistoryId = fdRenewalHistoryId;
slab.chartName = chartName;
slab.description = description;
slab.periodTypeEnum = periodTypeEnum;
slab.fromPeriod = fromPeriod;
slab.toPeriod = toPeriod;
slab.amountRangeFrom = amountRangeFrom;
slab.amountRangeTo = amountRangeTo;
slab.annualInterestRate = annualInterestRate;
slab.currencyCode = currencyCode;
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
}
@@ -0,0 +1,21 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository<FixedDepositRenewalChartSlabHistory, Long> {
/** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */
List<FixedDepositRenewalChartSlabHistory> findByFdRenewalHistoryId(Long fdRenewalHistoryId);
}
@@ -0,0 +1,158 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.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 lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One row per term of one logical Fixed Deposit, recording the renewal event that started it.
* <p>
* Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable
* ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is
* what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is
* what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried
* forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a
* user, and by whom.
*/
@Entity
@Table(name = "m_fd_renewal_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalHistory {
/** Renewal ran automatically when the maturity job processed the account's maturity instruction. */
public static final String RENEWAL_TYPE_AUTO = "AUTO";
/** A user closed the account with a re-invest instruction. */
public static final String RENEWAL_TYPE_MANUAL = "MANUAL";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_MATURED = "MATURED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The stable customer-facing FD number, shared by every term in the chain. */
@Column(name = "fd_account_number", length = 20, nullable = false)
private String fdAccountNumber;
/** The account the customer originally opened, i.e. the anchor of the chain. */
@Column(name = "original_savings_account_id", nullable = false)
private Long originalSavingsAccountId;
/** The deposit instance this term runs on. */
@Column(name = "savings_account_id", nullable = false)
private Long savingsAccountId;
/** The term this one was renewed from; null on the chain's first row. */
@Column(name = "previous_savings_account_id")
private Long previousSavingsAccountId;
@Column(name = "previous_renewal_id")
private Long previousRenewalId;
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
@Column(name = "renewal_no", nullable = false)
private Integer renewalNo;
@Column(name = "previous_principal", scale = 6, precision = 19)
private BigDecimal previousPrincipal;
@Column(name = "interest_earned", scale = 6, precision = 19)
private BigDecimal interestEarned;
@Column(name = "maturity_amount", scale = 6, precision = 19)
private BigDecimal maturityAmount;
/** What actually carried into this term: the full maturity amount, or principal only. */
@Column(name = "renewed_amount", scale = 6, precision = 19)
private BigDecimal renewedAmount;
@Column(name = "interest_rate", scale = 6, precision = 19)
private BigDecimal interestRate;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "tenure")
private Integer tenure;
@Column(name = "tenure_frequency_enum")
private Integer tenureFrequencyEnum;
@Column(name = "renewal_type", length = 10)
private String renewalType;
@Column(name = "renewal_status", length = 20)
private String renewalStatus;
@Column(name = "renewed_on")
private LocalDate renewedOn;
@Column(name = "created_by_id")
private Long createdById;
public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId,
final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo,
final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount,
final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate,
final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn,
final Long createdById) {
final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory();
history.fdAccountNumber = fdAccountNumber;
history.originalSavingsAccountId = originalSavingsAccountId;
history.savingsAccountId = savingsAccountId;
history.previousSavingsAccountId = previousSavingsAccountId;
history.previousRenewalId = previousRenewalId;
history.renewalNo = renewalNo;
history.previousPrincipal = previousPrincipal;
history.interestEarned = interestEarned;
history.maturityAmount = maturityAmount;
history.renewedAmount = renewedAmount;
history.interestRate = interestRate;
history.startDate = startDate;
history.maturityDate = maturityDate;
history.tenure = tenure;
history.tenureFrequencyEnum = tenureFrequencyEnum;
history.renewalType = renewalType;
history.renewalStatus = STATUS_ACTIVE;
history.renewedOn = renewedOn;
history.createdById = createdById;
return history;
}
/** Called on the previous term's row once its successor takes over. */
public void markMatured() {
this.renewalStatus = STATUS_MATURED;
}
/**
* Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's
* own row so each term reports its own outcome rather than that of its predecessor.
*/
public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) {
this.interestEarned = interestEarned;
this.maturityAmount = maturityAmount;
}
}
@@ -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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalHistoryRepository extends JpaRepository<FixedDepositRenewalHistory, Long> {
/** One FD's whole history, oldest term first. */
List<FixedDepositRenewalHistory> findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber);
/**
* An account's terms, newest first.
* <p>
* Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at
* that one account, so callers wanting the term currently running take the first element. Empty for an account that
* pre-dates this table or was never renewed.
*/
List<FixedDepositRenewalHistory> findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId);
}
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominati
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName;
@@ -81,6 +82,7 @@ import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
@@ -406,6 +408,28 @@ public class SavingsAccountAssembler {
account.setShareLinkingEnabled(account.savingsProduct().isShareLinkingEnabled());
}
// Which share account this savings account is linked to - required once Share Linking is
// enabled, since a "Yes" with nothing chosen would leave the toggle meaning nothing. Checked
// against m_share_account directly (raw SQL, not the JPA entity - fineract-savings has no
// 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.
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 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
if (command.parameterExists(isSeniorCitizenParamName)) {
final Boolean scFlag = this.fromApiJsonHelper.extractBooleanNamed(isSeniorCitizenParamName, element);
@@ -114,13 +114,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final LocalDate today = DateUtils.getBusinessLocalDate();
if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) {
account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
if (account.isPostWithdrawalInterestRecalculationRequired(transactionBooleanValues.isInterestTransfer())) {
if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) {
account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
} else {
account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(),
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate,
backdatedTxnsAllowedTill, postReversals);
}
} else {
account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(),
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill,
postReversals);
account.refreshBalancesAndSummaryAfterTransfer(today, backdatedTxnsAllowedTill, postReversals);
}
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
@@ -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.savings.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
@Autowired
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.FacilityChargeMappingWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FACILITYCHARGEMAPPING", action = "UPDATE")
@RequiredArgsConstructor
public class UpdateFacilityChargeMappingCommandHandler implements NewCommandSourceHandler {
private final FacilityChargeMappingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateMapping(command.entityId(), command);
}
}
@@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -636,14 +637,20 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
for (final String facilityType : newlyEnabledFacilityTypes) {
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} newly-enabled via edit-approval but NO mapping found in m_facility_charge_mapping",
// An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's
// requireMappedCharge for why.
if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped",
"A charge has not been mapped for the '" + facilityType
+ "' facility. Configure it under Products > Facility Charge Mappings before enabling it.",
facilityType);
continue;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
continue;
if (charge.isDeleted() || !charge.isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted",
"The charge mapped to the '" + facilityType
+ "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.",
facilityType);
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge);
@@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
.append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ")
.append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ")
.append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ")
.append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ")
.append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ")
@@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom");
final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo");
final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate");
final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate");
// currency Slabs
final String currencyCode = rs.getString("currencyCode");
@@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
currencyNameCode);
return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, currency);
amountRangeTo, annualInterestRate, currency, seniorCitizenRate);
}
}
@@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService {
DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId);
/**
* As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries
* PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it.
*/
DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId);
DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
Collection<SavingsAccountTransactionData> retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId);
@@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
}
}
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId);
}
}
return account;
@@ -329,6 +332,158 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}
}
/**
* Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the
* account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest
* Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW
* account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row
* points at it.
*/
private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result,
final Long accountId) {
final String fromSql = "select src.id as id, src.account_no as accountNo "
+ "from m_deposit_account_term_and_preclosure t "
+ "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?";
final String intoSql = "select dest.id as id, dest.account_no as accountNo "
+ "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id "
+ "where t.reinvested_from_savings_id = ?";
Long fromId = null;
String fromNo = null;
final java.util.List<java.util.Map<String, Object>> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId);
if (!fromRows.isEmpty()) {
fromId = ((Number) fromRows.get(0).get("id")).longValue();
fromNo = (String) fromRows.get(0).get("accountNo");
}
Long intoId = null;
String intoNo = null;
final java.util.List<java.util.Map<String, Object>> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId);
if (!intoRows.isEmpty()) {
intoId = ((Number) intoRows.get(0).get("id")).longValue();
intoNo = (String) intoRows.get(0).get("accountNo");
}
if (fromId != null || intoId != null) {
result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo);
}
// The number to actually show as "the FD account number" -- the original account's account_no
// if this account has been through a renewal, otherwise this account's own account_no.
final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure "
+ "where savings_account_id = ?";
final java.util.List<java.util.Map<String, Object>> termRows = this.jdbcTemplate.queryForList(termSql, accountId);
final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number");
final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no");
final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo();
result.setFdAccountNumber(rootFdAccountNumber);
result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue());
result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber));
}
/**
* Every term of one logical FD, oldest first.
* <p>
* Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it
* matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by
* joining account rows, which cannot express any of that. The account row is joined in for its number and live
* status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as
* simply a non-empty list.
*/
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistory(final String fdAccountNumber) {
// Every row states its own term's figures, so this is a straight read: interest_earned and
// maturity_amount are filled in when that term closes, and stay null on the term still running,
// which the caller renders as its projected maturity instead.
final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, "
+ "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, "
+ "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, "
+ "h.tenure_frequency_enum as tenureFrequencyEnum, "
+ "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, "
+ "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, "
+ "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h "
+ "join m_savings_account sa on sa.id = h.savings_account_id "
+ "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where h.fd_account_number = ? " + "order by h.renewal_no asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber);
// Any row here is a renewal event that was actually recorded, so even one is worth returning --
// discarding a lone row used to hide a genuine renewal (and its interest figure) behind the
// account-derived fallback, which then returned nothing at all for it.
if (!rows.isEmpty()) {
attachArchivedChartSlabs(rows);
return rows;
}
// Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to
// reconstructing what the account rows alone can tell us. The per-term interest/renewal-type
// columns are simply absent for those, which the UI renders as blanks rather than wrong values.
return retrieveRenewalHistoryFromAccounts(fdAccountNumber);
}
/**
* Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row.
* <p>
* A term's chart is only archived when it closes via an in-place renewal (see
* {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before
* that archiving existed simply get an empty list here -- callers should fall back to the account's live
* accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since
* this runs once per FD account view.
*/
private void attachArchivedChartSlabs(final java.util.List<java.util.Map<String, Object>> rows) {
final java.util.List<Long> historyIds = new java.util.ArrayList<>();
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
if (id != null) {
historyIds.add(((Number) id).longValue());
}
}
if (historyIds.isEmpty()) {
return;
}
final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?"));
final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, "
+ "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, "
+ "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate "
+ "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") "
+ "order by from_period asc";
final java.util.List<java.util.Map<String, Object>> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray());
final java.util.Map<Long, java.util.List<java.util.Map<String, Object>>> slabsByHistoryId = new java.util.HashMap<>();
for (final java.util.Map<String, Object> slabRow : slabRows) {
final Long historyId = ((Number) slabRow.remove("historyId")).longValue();
slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow);
}
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
final Long historyId = id == null ? null : ((Number) id).longValue();
row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList()));
}
}
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) {
// Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has
// to know which path produced the rows. What the account rows genuinely cannot tell us -- each
// term's interest earned, and whether the rollover was automatic or done by staff -- is returned as
// an explicit null rather than omitted, so those render as blanks instead of the previous behaviour
// where a missing renewalStatus made every term, including the running one, look matured.
final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, "
+ "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, "
+ "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, "
+ "t.maturity_date as maturityDate, t.deposit_period as tenure, "
+ "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, "
+ "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, "
+ "sa.status_enum as statusEnum, null as interestEarned, "
+ "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, "
+ "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount "
+ "from m_savings_account sa "
+ "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber,
fdAccountNumber);
// A single row here means this FD simply never renewed -- one account, one term -- so there is no
// history to show. Unlike the primary path, where even one recorded row is a real renewal event.
return rows.size() > 1 ? rows : java.util.Collections.emptyList();
}
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
@@ -349,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}, accountId);
}
/**
* Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the
* Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it
* needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for
* it and would fall through to paying the whole balance out in cash.
*/
@Override
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = false;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
/**
* The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that
* implements it.
*/
@Override
public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = true;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId,
final boolean includePartialReinvest) {
try {
this.context.authenticatedUser();
@@ -367,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(DepositAccountOnClosureType.values());
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -554,7 +733,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template,
accountChart, null, null);
// Re-apply CBS product fields lost during factory method object creation
@@ -626,7 +805,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
}
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -106,6 +107,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
@@ -560,6 +562,72 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
.build();
}
/**
* FD Special (monthly interest payout) shows customers/staff exactly how much interest is projected to
* post each month across the account's whole term, rather than a flat rate/12 approximation -- computed
* with the real PostingPeriod engine (same one the actual maturity calculation uses) via
* FixedDepositAccount#projectInterestSchedule, which is deliberately side-effect-free. readOnly=true
* overrides this class's default @Transactional so nothing from that computation is ever flushed.
*/
@Transactional(readOnly = true)
@Override
public List<Map<String, Object>> retrieveProjectedInterestSchedule(final Long savingsId, final DepositAccountType depositAccountType) {
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
if (!(account instanceof FixedDepositAccount)) {
return java.util.Collections.emptyList();
}
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final MathContext mc = new MathContext(15, MoneyHelper.getRoundingMode());
final FixedDepositAccount fdAccount = (FixedDepositAccount) account;
final List<PostingPeriod> postingPeriods = fdAccount.projectInterestSchedule(mc, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
// determineInterestPostingPeriods always rounds the LAST period out to the full natural boundary
// (e.g. calendar month-end for Monthly posting) even when the account's own maturity date falls
// partway through it -- the INTEREST AMOUNT for that period is still correctly computed only up to
// the real cutoff (interestCalculationUpto = maturityDate - 1, the same day postMaturityInterest
// clamps its own posting date to), but the period's displayed end-date is not, and would otherwise
// show a "To Date" past the account's actual maturity date. Clip it here so the schedule shown
// never claims interest for days beyond maturity, matching the money that is actually calculated.
final LocalDate lastCalculableDate = fdAccount.maturityDate() != null ? fdAccount.maturityDate().minusDays(1) : null;
// A period is "paid" once a real (non-reversed) interest-posting transaction actually landed for it.
// Matched EXACTLY against that period's own end date (Fineract can date the posting transaction
// either on the period's own last day, or on the first day of the following period, depending on
// the isSavingsInterestPostingAtCurrentPeriodEnd configuration) -- not a date RANGE, which would
// wrongly also match the very next period (whose own start date is that same "day after").
final List<LocalDate> postedInterestDates = new ArrayList<>();
for (final SavingsAccountTransaction transaction : fdAccount.getTransactions()) {
if (transaction.isInterestPostingAndNotReversed()) {
postedInterestDates.add(transaction.getTransactionDate());
}
}
final List<Map<String, Object>> schedule = new ArrayList<>();
for (final PostingPeriod period : postingPeriods) {
final LocalDate fromDate = period.getPeriodInterval().startDate();
LocalDate toDate = period.getPeriodInterval().endDate();
if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) {
toDate = lastCalculableDate;
}
final LocalDate toDateFinal = toDate;
final boolean paid = postedInterestDates.stream()
.anyMatch(postedDate -> postedDate.isEqual(toDateFinal) || postedDate.isEqual(toDateFinal.plusDays(1)));
final Map<String, Object> row = new HashMap<>();
row.put("fromDate", fromDate);
row.put("toDate", toDateFinal);
row.put("interestAmount", period.getInterestEarned().getAmount());
row.put("paid", paid);
schedule.add(row);
}
return schedule;
}
@Transactional
@Override
public CommandProcessingResult postInterest(final Long savingsId, final DepositAccountType depositAccountType) {
@@ -912,6 +980,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Override
public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
this.depositAccountTransactionDataValidator.validatePayment(command);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
// Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close
// (Active only) -- but it must still be one of those two. Without this, a closed or
// pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to
// ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it.
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
if (!account.isActive() && !account.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured",
"Payment can only be processed on an active or matured fixed deposit account.", savingsId);
}
checkClientOrGroupActive(account);
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
final Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale());
final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null,
isInternalTransferLeg);
}
final String noteText = command.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.savingNote(account, noteText);
changes.put("note", noteText);
this.noteRepository.save(note);
}
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.with(changes)//
.build();
}
@Override
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
@@ -1393,18 +1522,37 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
// 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.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
Money amountForDeposit = account.activateWithBalance();
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
// Matching internal-transfer leg for the withdrawal the closure just made on the
// matured account: the funds never touch cash, so this posts against the
// transfer-suspense GL instead of debiting cash/vault.
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
amountForDeposit.getAmount(), null);
amountForDeposit.getAmount(), null, isInternalTransferLeg);
}
}
} else if (depositAccountType.isRecurringDeposit()) {
@@ -1421,6 +1569,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds());
}
/**
* Marks the account's withdrawal (and withdrawal-fee) transactions as already accounted for, so a
* subsequent postJournalEntries() built on an older snapshot does not post them again. Used after a
* maturity closure, whose withdrawal leg posts its own journal entries.
*/
private void markWithdrawalsAsAlreadyPosted(final SavingsAccount account, final Set<Long> existingTransactionIds) {
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
if (transaction.getId() != null && (transaction.isWithdrawal() || transaction.isWithdrawalFee())) {
existingTransactionIds.add(transaction.getId());
}
}
}
private void postJournalEntries(final SavingsAccount savingsAccount, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds) {
@@ -80,6 +80,7 @@ import org.apache.fineract.portfolio.group.exception.GroupNotFoundException;
import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
@@ -429,11 +430,18 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final Long savingsAccountId = command.longValueOfParameterNamed(DepositsApiConstants.linkedAccountParamName);
AccountAssociations accountAssociations = this.accountAssociationsRepository.findBySavingsIdAndType(accountId,
AccountAssociationType.LINKED_ACCOUNT_ASSOCIATION.getValue());
// Tracks whichever savings account ends up linked once this whole block finishes, across every
// branch below (kept as-is, removed, or replaced) -- used afterwards to re-enforce FD Special's
// "must always have a linked account" requirement regardless of what this request touched.
Long resolvedLinkedAccountId = accountAssociations != null && accountAssociations.linkedSavingsAccount() != null
? accountAssociations.linkedSavingsAccount().getId()
: null;
if (savingsAccountId == null) {
if (accountAssociations != null) {
if (this.fromJsonHelper.parameterExists(DepositsApiConstants.linkedAccountParamName, command.parsedJson())) {
this.accountAssociationsRepository.delete(accountAssociations);
changes.put(DepositsApiConstants.linkedAccountParamName, null);
resolvedLinkedAccountId = null;
if (isLinkedAccRequired) {
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
}
@@ -465,6 +473,23 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
changes.put(DepositsApiConstants.linkedAccountParamName, savingsAccountId);
this.accountAssociationsRepository.save(accountAssociations);
}
resolvedLinkedAccountId = savingsAccountId;
}
// FD Special is a non-cumulative, monthly-interest-payout product: DepositAccountTermAndPreClosure#update
// above has no fdType awareness and will happily unset transferInterestToLinkedAccount,
// onAccountClosureType and transferToSavingsAccountId on this same request -- including by mere
// omission of maturityInstructionId/transferToSavingsId from the payload -- silently turning a SPECIAL
// FD into a plain cumulative one while it still displays as "FD Special". Re-force the three fields
// here, now that the linked-account resolution above has settled what account (if any) is linked.
if ("SPECIAL".equalsIgnoreCase(account.fdType())) {
if (resolvedLinkedAccountId == null) {
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
}
account.forceSpecialFdMonthlyPayoutSettings(resolvedLinkedAccountId);
changes.put(transferInterestToSavingsParamName, true);
changes.put(DepositsApiConstants.maturityInstructionIdParamName, DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue());
this.savingAccountRepository.save(account);
}
// Replace nominees if provided
@@ -119,7 +119,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
} else {
correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated());
}
if (correctionRequired) {
if (correctionRequired && !isInterestAlreadySweptOut(interestPostingTransactionDate, savingsAccountData)) {
boolean applyWithHoldTaxForOldTransaction = false;
postingTransaction.reverse();
@@ -185,6 +185,28 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
return savingsAccountData;
}
/**
* Mirrors {@code FixedDepositAccount#interestAlreadyPaidOut}: for an FD Special account whose interest sweeps
* out to a linked savings account, a withdrawal dated the same day as an interest posting means that period's
* interest has already left the account (see DepositAccountReadPlatformService#retrieveDataForInterestTransfer,
* which pairs them by date). Recalculating and reposting it here -- as this batch job otherwise would for
* every account, FD or not -- would silently disagree with money that has already moved. Scoped to FD Special
* only, so plain savings and recurring deposit batch posting is unaffected.
*/
private boolean isInterestAlreadySweptOut(final LocalDate interestPostingTransactionDate, final SavingsAccountData savingsAccountData) {
if (!savingsAccountData.isTransferInterestToLinkedAccount()
|| !DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId())) {
return false;
}
for (final SavingsAccountTransactionData transaction : savingsAccountData.getSavingsAccountTransactionData()) {
if (transaction.isWithdrawal() && transaction.isNotReversed()
&& DateUtils.isEqual(transaction.getTransactionDate(), interestPostingTransactionDate)) {
return true;
}
}
return false;
}
protected SavingsAccountTransactionData findTransactionFor(final LocalDate postingDate,
final List<SavingsAccountTransactionData> transactions) {
SavingsAccountTransactionData transaction = null;
@@ -341,8 +363,19 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
}
}
// A deposit that sweeps its interest out to a linked savings account every period (FD Special)
// must never compound: the interest does not stay in the account, so each period earns on the
// principal alone. This used to be hard-coded false here, which made this batch engine disagree
// with the domain engine (SavingsAccount#calculateInterestUsing, which passes the real flag) --
// the same account could be posted a compounded amount by the nightly job and a simple amount by
// its own maturity run, minutes apart. Deliberately scoped to fixed deposits so recurring
// deposits keep their existing behaviour.
// getDepositTypeId() rather than depositAccountType(), which dereferences the enum unguarded --
// this runs per account inside a batch job, so a null deposit type must not take the whole run down.
final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
getLockedInUntilLocalDate(savingsAccountData), false);
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
@@ -349,6 +349,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final StringBuilder sqlBuilder = new StringBuilder(400);
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
sqlBuilder.append("sa.status_enum as statusEnum, ");
@@ -446,6 +447,12 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 ");
sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 ");
sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 ");
// Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a
// cumulative one: interest that is swept out to a linked account each month must never
// compound, and without this flag the engine compounded it. Left join because ordinary
// savings accounts have no term-and-preclosure row at all.
sqlBuilder.append(
"left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id ");
this.schemaSql = sqlBuilder.toString();
}
@@ -668,6 +675,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings);
savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl);
// Monthly-payout deposits must not compound: their interest leaves the account every
// period, so each period earns on the principal alone.
savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount"));
}
if (!transMap.containsValue(transactionId)) {
@@ -873,6 +883,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, ");
sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, ");
sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, ");
sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, ");
// Facility config fields
sqlBuilder.append("safc.atm_facility as atmFacility, ");
sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, ");
@@ -905,6 +916,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid ");
sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id ");
sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id ");
sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id ");
this.schemaSql = sqlBuilder.toString();
}
@@ -1131,6 +1143,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null);
accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null);
accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null);
accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId"));
accountData.setShareAccountNo(rs.getString("shareAccountNo"));
accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null);
// Nominee as parsed map
@@ -1142,8 +1156,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
java.util.Map<String, Object> nomineeMap = gson.fromJson(nomineeDataStr,
new com.google.gson.reflect.TypeToken<java.util.Map<String, Object>>() {}.getType());
accountData.setNomineeData(nomineeMap);
} catch (Exception ignored) {
// intentionally ignored
} catch (Exception ignored) {
// intentionally ignored
}
}
@@ -97,6 +97,8 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -120,7 +122,6 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException;
import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException.PostInterestAsOnExceptionType;
import org.apache.fineract.portfolio.savings.exception.PostInterestClosingDateException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountClosingNotAllowedException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentException;
@@ -140,6 +141,8 @@ import org.springframework.util.CollectionUtils;
public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService {
private final PlatformSecurityContext context;
private final VoucherService voucherService;
private static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
private final SavingsAccountDataValidator fromApiJsonDeserializer;
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
private final StaffRepositoryWrapper staffRepository;
@@ -345,6 +348,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.noteRepository.save(note);
}
// Money arriving at the counter: cash is debited and the member's savings control account
// is credited.
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true);
if (depositVoucherNo != null) {
changes.put("voucherNo", depositVoucherNo);
}
return new CommandProcessingResultBuilder() //
.withEntityId(deposit.getId()) //
.withOfficeId(account.officeId()) //
@@ -421,6 +431,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.noteRepository.save(note);
}
// Money leaving the member over the counter: their savings control account is debited and
// the till that pays it out is credited.
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
if (withdrawalVoucherNo != null) {
changes.put("voucherNo", withdrawalVoucherNo);
}
return new CommandProcessingResultBuilder() //
.withEntityId(withdrawal.getId()) //
.withOfficeId(account.officeId()) //
@@ -585,6 +602,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
// Interest credited to the member, recorded against the transactions this run created.
// existingTransactionIds is what the account held before posting, so anything not in it
// is new — which is how a re-run over an account that already had interest posted last
// quarter raises a voucher for this quarter's credit only.
raiseInterestVouchers(account, existingTransactionIds, backdatedTxnsAllowedTill);
}
}
@@ -643,7 +666,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable());
}
} else {
accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestPayable());
accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestOnSavings());
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl());
}
}
@@ -955,22 +978,26 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
final boolean isPostInterest = command.booleanPrimitiveValueOfParameterNamed(SavingsApiConstants.postInterestValidationOnClosure);
// postInterest(account,closedDate,flag);
if (isPostInterest) {
boolean postInterestOnClosingDate = false;
List<SavingsAccountTransaction> savingTransactions = account.getTransactions();
for (SavingsAccountTransaction savingTransaction : savingTransactions) {
if (savingTransaction.isInterestPosting() && savingTransaction.isNotReversed()
&& DateUtils.isEqual(closedDate, savingTransaction.getTransactionDate())) {
postInterestOnClosingDate = true;
break;
}
}
if (!postInterestOnClosingDate) {
throw new PostInterestClosingDateException();
// Pro-rata interest for whatever partial period has accrued since the account's last posting is
// calculated and posted here, unconditionally -- closing used to only ever *validate* that
// interest had already been posted on the closing date (gated behind
// postInterestValidationOnClosure, off by default), so with nothing manually posted beforehand
// the partial period's interest was simply never paid. postInterest(...) already no-ops for a
// zero-interest product (its own rate>0 guard) and for a period with nothing newly due, so
// calling it here on every closure is safe -- it only ever adds the posting that closing an
// account on a real business day should always have included.
boolean interestAlreadyPostedOnClosingDate = false;
for (SavingsAccountTransaction savingTransaction : account.getTransactions()) {
if (savingTransaction.isInterestPosting() && savingTransaction.isNotReversed()
&& DateUtils.isEqual(closedDate, savingTransaction.getTransactionDate())) {
interestAlreadyPostedOnClosingDate = true;
break;
}
}
if (!interestAlreadyPostedOnClosingDate) {
this.postInterest(account, true, closedDate, false);
}
final Map<String, Object> changes = new LinkedHashMap<>();
@@ -1676,6 +1703,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusInactive(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Same snapshot-vs-execution gap as escheat() below: the dormant-accounts batch selects
// pending-inactive ids from a query that requires status = ACTIVE, but the account can close
// through another path (e.g. deposit maturity) before this call runs. Re-deriving accounting
// off the full transaction list at that point re-posts entries that closure already posted.
if (!account.isActive()) {
log.warn("Skipping setSubStatusInactive for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -1687,6 +1723,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusDormant(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
if (!account.isActive()) {
log.warn("Skipping setSubStatusDormant for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
account.setSubStatusDormant();
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@@ -1694,6 +1735,16 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void escheat(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// The dormant-accounts batch selects escheat-eligible ids from a snapshot query, so an
// account can legitimately close through another path (e.g. deposit maturity) in the gap
// between that selection and this call. escheat() unconditionally forces status/sub-status
// to CLOSED/ESCHEAT and re-derives accounting off the account's full transaction list, so
// running it against an already-closed account both mislabels the closure reason and
// re-posts journal entries for transactions that were already posted by the real closure.
if (!account.isActive()) {
log.warn("Skipping escheat for savings account {} -- no longer active (already closed by another process)", savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -1811,7 +1862,14 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.savingsAccountTransactionDataValidator.validateHoldAndAssembleForm(command.json(), account, submittedBy,
backdatedTxnsAllowedTill);
SavingsAccountTransaction transaction = this.savingsAccountDomainService.handleHold(account, amount, transactionDate, lienAllowed);
account.holdAmount(amount);
// Defaults to Cash Hold (a manual/staff-placed lien) -- the automatic clearing hold placed on a
// Cheque/DD deposit at approval time is the only caller expected to pass CLEARING explicitly.
final String requestedHoldCategory = command.stringValueOfParameterNamed(SavingsApiConstants.holdCategoryParamName);
final String holdCategory = SavingsAccount.HOLD_CATEGORY_CLEARING.equals(requestedHoldCategory)
? SavingsAccount.HOLD_CATEGORY_CLEARING
: SavingsAccount.HOLD_CATEGORY_CASH;
account.holdAmount(amount, holdCategory);
transaction.updateHoldCategory(holdCategory);
transaction.setRunningBalance(runningBalance);
final String reasonForBlock = command.stringValueOfParameterNamed(SavingsApiConstants.reasonForBlockParamName);
@@ -1859,7 +1917,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
transaction.setRunningBalance(runningBalance);
this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transaction.getTransactionDate(), account);
account.releaseOnHoldAmount(transaction.getAmount());
// Credit back whichever bucket the original hold debited -- a Clearing Hold released here (the
// manual "release early" action) must not accidentally credit the Cash Hold total instead.
final String holdCategory = holdTransaction.getHoldCategory() != null ? holdTransaction.getHoldCategory()
: SavingsAccount.HOLD_CATEGORY_CASH;
account.releaseOnHoldAmount(transaction.getAmount(), holdCategory);
this.savingsAccountTransactionRepository.saveAndFlush(transaction);
holdTransaction.updateReleaseId(transaction.getId());
@@ -1970,4 +2032,79 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory");
}
}
}
private void raiseInterestVouchers(final SavingsAccount account, final Set<Long> existingTransactionIds,
final boolean backdatedTxnsAllowedTill) {
if (!this.voucherService.shouldRaiseVoucher()) {
return;
}
try {
final List<SavingsAccountTransaction> transactions = backdatedTxnsAllowedTill
? account.getSavingsAccountTransactionsWithPivotConfig()
: account.getTransactions();
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;
final String partyName = party != null ? party : (String) control.get("name");
final String accountNo = account.getAccountNumber();
final AppUser currentUser = this.context.authenticatedUser();
for (final SavingsAccountTransaction transaction : transactions) {
if (transaction.getId() == null || existingTransactionIds.contains(transaction.getId())
|| !transaction.isInterestPostingAndNotReversed()) {
continue;
}
final BigDecimal amount = transaction.getAmount();
if (amount == null || amount.signum() <= 0) {
continue;
}
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT,
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 credited")));
}
} catch (final RuntimeException e) {
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
}
}
private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount,
final boolean isDeposit) {
if (!this.voucherService.shouldRaiseVoucher()) {
return null;
}
try {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
final String accountNo = account.getAccountNumber();
final VoucherLine memberSide = isDeposit
? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount)
: VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount);
final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount)
: VoucherLine.creditGl(null, "Cash in Hand", null, amount);
final List<VoucherLine> lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide);
return this.voucherService.createVoucherWithLines(
isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId,
currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for savings transaction {}", transactionId, e);
return null;
}
}
}
@@ -116,6 +116,7 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
private final GroupRepositoryWrapper groupRepositoryWrapper;
private final GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -257,6 +258,16 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
}
final Long savingsId = account.getId();
// Mirrors the link back onto the share account this savings account was linked to at
// creation (share account -> savings account already existed; this is the reverse
// pointer), so either side can be navigated to from the other, the same way the "Default
// Savings Account" chosen on a share account points back at it.
if (account.getShareAccountId() != null) {
this.jdbcTemplate.update("UPDATE m_share_account SET savings_account_id = ? WHERE id = ?", savingsId,
account.getShareAccountId());
}
if (command.parameterExists(SavingsApiConstants.datatables)) {
this.entityDatatableChecksWritePlatformService.saveDatatables(StatusEnum.CREATE.getValue(), EntityTables.SAVINGS.getName(),
savingsId, account.productId(), command.arrayOfParameterNamed(SavingsApiConstants.datatables));
@@ -69,6 +69,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
@@ -473,9 +474,10 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler,
savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
@@ -526,13 +528,13 @@ public class SavingsConfiguration {
AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService,
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GSIMRepositoy gsimRepository,
GroupRepositoryWrapper groupRepositoryWrapper, GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService,
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
ClientNomineeWritePlatformService clientNomineeWritePlatformService, org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) {
return new SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, savingAccountAssembler,
savingsAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository,
noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler,
commandProcessingService, savingsAccountDomainService, savingsAccountWritePlatformService, accountNumberFormatRepository,
businessEventNotifierService, entityDatatableChecksWritePlatformService, gsimRepository, groupRepositoryWrapper,
gsimWritePlatformService, clientNomineeWritePlatformService);
gsimWritePlatformService, clientNomineeWritePlatformService, jdbcTemplate);
}
@Bean
@@ -0,0 +1,77 @@
package org.apache.fineract.portfolio.savingshold.api;
import com.google.gson.Gson;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Auto-releases a Clearing Hold once its instrument's clearing period has passed — real-world CBS
* practice for a cheque/DD deposit that was held pending clearance (see
* {@code CashierDepositRequestApiResource.placeClearingHoldIfInstrumentDeposit}). This is a plain,
* self-contained REST trigger rather than a job registered in Fineract's Spring Batch scheduler: the
* batch job framework is a deep, unfamiliar subsystem to extend correctly, and a wrong registration
* there risks the whole scheduler, not just this feature. Wire this endpoint into an external
* scheduler (cron / the existing OS-level task runner) to run it daily; a manager can also call it
* directly, or release one hold early and by hand via the existing releaseAmount action, for an
* instrument confirmed cleared sooner than the default window.
*/
@Path("/v1/savingsaccounts/clearingholds/release-due")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class SavingsClearingHoldReleaseApiResource {
/** Kept as a simple constant rather than a new admin-editable setting -- the clearing window this
* branch actually needs can be tuned here once real usage shows what it should be. */
private static final int CLEARING_HOLD_DEFAULT_DAYS = 3;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
@POST
public String releaseDue() {
this.context.authenticatedUser();
final LocalDate cutoff = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).minusDays(CLEARING_HOLD_DEFAULT_DAYS);
final List<Map<String, Object>> due = this.jdbcTemplate.queryForList("""
SELECT id, savings_account_id FROM m_savings_account_transaction
WHERE hold_category = 'CLEARING' AND release_id_of_hold_amount IS NULL
AND is_reversed = false AND transaction_date <= ?
""", java.sql.Date.valueOf(cutoff));
final List<Map<String, Object>> released = new ArrayList<>();
final List<Map<String, Object>> failed = new ArrayList<>();
for (final Map<String, Object> row : due) {
final Long savingsId = ((Number) row.get("savings_account_id")).longValue();
final Long transactionId = ((Number) row.get("id")).longValue();
try {
final CommandWrapper commandRequest = new CommandWrapperBuilder().releaseAmount(savingsId, transactionId)
.withJson("{}").build();
this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
released.add(Map.of("savingsId", savingsId, "transactionId", transactionId));
} catch (final RuntimeException e) {
failed.add(Map.of("savingsId", savingsId, "transactionId", transactionId, "error", String.valueOf(e.getMessage())));
}
}
return this.gson.toJson(
Map.of("cutoffDate", cutoff.toString(), "releasedCount", released.size(), "released", released, "failed", failed));
}
}
@@ -0,0 +1,49 @@
package org.apache.fineract.portfolio.savingshold.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
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 two-way hold breakdown behind a savings account's single combined "amount on hold" figure —
* a manual, staff-placed lien (Cash Hold) versus money still moving through the clearing process, a
* cheque/DD deposit not yet realized or a presented cheque not yet settled (Clearing Hold).
*
* Kept as a narrow, additive endpoint alongside the standard Fineract savings-account read API rather
* than adding a field to {@code SavingsAccountData} — that class has roughly a dozen constructor
* overloads threading its existing fields through every savings API response, and touching all of them
* for one screen's two extra numbers would be a wide, easy-to-regress change for a small addition.
*/
@Path("/v1/savingsaccounts/{savingsId}/holdbreakdown")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class SavingsHoldBreakdownApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String retrieve(@PathParam("savingsId") final Long savingsId) {
this.context.authenticatedUser();
final Map<String, Object> row = this.jdbcTemplate.queryForMap("""
SELECT COALESCE(total_savings_amount_on_hold, 0) AS cash_hold,
COALESCE(total_savings_amount_on_hold_clearing, 0) AS clearing_hold
FROM m_savings_account WHERE id = ?
""", savingsId);
return this.gson.toJson(Map.of("savingsId", savingsId, "cashHold", (BigDecimal) row.get("cash_hold"),
"clearingHold", (BigDecimal) row.get("clearing_hold")));
}
}
@@ -0,0 +1,178 @@
/**
* 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.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.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.security.service.PlatformSecurityContext;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Can a member fund a <i>new</i> additional-share tranche from their savings, and for how much.
*
* <p>
* Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the
* caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and
* priced at the product's current market price, which is the same figure the apply form shows the member.
*/
@Path("/v1/shareaccounts/{accountId}/additionalshares")
@Component
@Tag(name = "Additional Shares Funding", description = "Whether a new additional-share tranche can be funded from the member's linked savings.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class AdditionalSharesFundingApiResource {
private static final int SAVINGS_STATUS_ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* @param requestedShares
* how many shares the member wants to buy; the amount is derived from this rather than trusted from the
* caller, so the eligibility answer and the tranche's own value cannot disagree.
*/
@GET
@Path("/savings-eligibility")
@Operation(summary = "Savings eligibility for a new additional-share tranche", description = "Reports the tranche's value and whether the member's linked savings account can cover it.")
public Response savingsEligibility(@PathParam("accountId") final Long accountId,
@QueryParam("requestedShares") final Long requestedShares) {
this.context.authenticatedUser();
final Map<String, Object> acct = loadShareAccount(accountId);
final BigDecimal unitPrice = currentMarketPriceOf(acct.get("product_id"));
final long shares = requestedShares == null ? 0L : requestedShares;
final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("shareAccountId", accountId);
out.put("requestedShares", shares);
out.put("unitPrice", unitPrice);
out.put("amount", amount);
final Object savingsAccountIdRaw = acct.get("savings_account_id");
if (savingsAccountIdRaw == null) {
return ineligible(out, null, null, "This share account has no linked savings account.");
}
final Map<String, Object> savings = loadSavingsAccount(toLong(savingsAccountIdRaw));
final String savingsAccountNo = (String) savings.get("account_no");
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
out.put("savingsAccountNo", savingsAccountNo);
out.put("savingsBalance", balance);
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
return ineligible(out, savingsAccountNo, balance, "Savings account " + savingsAccountNo + " is not active.");
}
// Zero shares is not a shortfall — the clerk simply has not typed the count yet, and saying
// the balance is short would be wrong as well as unhelpful.
if (shares <= 0) {
return ineligible(out, savingsAccountNo, balance, "Enter the number of shares to check the balance against.");
}
if (balance == null || balance.compareTo(amount) < 0) {
return ineligible(out, savingsAccountNo, balance,
"Savings account " + savingsAccountNo + " does not hold enough to cover this share request.");
}
out.put("eligible", Boolean.TRUE);
out.put("reason", null);
return Response.ok(gson.toJson(out)).build();
}
private Response ineligible(final Map<String, Object> out, final String savingsAccountNo, final BigDecimal balance,
final String reason) {
out.putIfAbsent("savingsAccountNo", savingsAccountNo);
out.putIfAbsent("savingsBalance", balance);
out.put("eligible", Boolean.FALSE);
out.put("reason", reason);
return Response.ok(gson.toJson(out)).build();
}
/**
* The product's price on the latest market-price period that has already started, matching how
* {@code ShareProduct.deriveMarketPrice} chooses one; the product's own unit price when no period applies.
*/
private BigDecimal currentMarketPriceOf(final Object productIdRaw) {
final Long productId = toLong(productIdRaw);
final List<Map<String, Object>> periods = this.jdbcTemplate.queryForList("""
SELECT share_value FROM m_share_product_market_price
WHERE product_id = ? AND from_date <= CURRENT_DATE
ORDER BY from_date DESC LIMIT 1
""", productId);
if (!periods.isEmpty()) {
final BigDecimal periodPrice = (BigDecimal) periods.get(0).get("share_value");
if (periodPrice != null) {
return periodPrice;
}
}
final BigDecimal unitPrice = this.jdbcTemplate.queryForObject("SELECT unit_price FROM m_share_product WHERE id = ?",
BigDecimal.class, productId);
return unitPrice == null ? BigDecimal.ZERO : unitPrice;
}
private Map<String, Object> loadShareAccount(final Long accountId) {
try {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, client_id, product_id, status_enum, currency_code, savings_account_id "
+ " FROM m_share_account WHERE id = ?",
accountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.share.account.not.found",
"Share account " + accountId + " does not exist.");
}
}
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
try {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.share.savings.account.not.found",
"Savings account " + savingsAccountId + " does not exist.");
}
}
private static Long toLong(final Object value) {
return value == null ? null : ((Number) value).longValue();
}
private static int toInt(final Object value) {
return value == null ? 0 : ((Number) value).intValue();
}
}
@@ -0,0 +1,67 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.shareaccounts.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import 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;
/**
* GET /v1/clients/{clientId}/activeshareaccounts
*
* A client's active share accounts, for the "Default Share Account" picker the Savings Account
* creation screen shows once Share Linking is enabled - the reverse of the "Default Savings
* Account" picker the Share Account creation screen already shows (which comes from
* {@code ShareAccountReadPlatformServiceImpl#retrieveActiveForLookup}). Deliberately its own small
* endpoint rather than folded into the savings account template response, which is shared by
* Savings, Fixed Deposit, Recurring Deposit and Current accounts alike - bolting a share-specific
* list onto that one shared, deeply-nested DTO would touch every one of those account types for a
* field only Savings actually uses.
*/
@Path("/v1/clients/{clientId}/activeshareaccounts")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ClientActiveShareAccountsApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String retrieve(@PathParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id AS id, sa.account_no AS "accountNo", sp.name AS "productName"
FROM m_share_account sa JOIN m_share_product sp ON sp.id = sa.product_id
WHERE sa.client_id = ? AND sa.status_enum = 300
ORDER BY sa.account_no
""", clientId);
return gson.toJson(rows);
}
}
@@ -0,0 +1,107 @@
/**
* 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.core.MediaType;
import jakarta.ws.rs.core.Response;
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;
/**
* Share accounts awaiting approval, for the checker's Pending Share Accounts queue.
*
* <p>
* Separate from {@code /accounts/share}, whose {@code retrieveAll} is hard-wired to ACTIVE accounts and returns the full
* account graph — charges, every purchase transaction — per row. A queue needs the opposite: only accounts in
* "submitted and pending approval", and only the handful of fields a checker decides on, so the list stays one cheap
* query however many accounts are waiting.
*/
@Path("/v1/shareaccounts/pending")
@Component
@Tag(name = "Pending Share Accounts", description = "Share accounts submitted and awaiting a checker's approval.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class PendingShareAccountsApiResource {
/** m_share_account.status_enum for "submitted and pending approval". */
private static final int STATUS_SUBMITTED_AND_PENDING_APPROVAL = 100;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Operation(summary = "List share accounts awaiting approval", description = "Accounts in submitted-and-pending-approval, with the member, product, requested shares and value a checker needs to decide.")
public Response retrievePending() {
this.context.authenticatedUser();
// The requested shares come from the account's own pending purchase rows rather than
// total_pending_shares, and are summed: an account can carry more than one pending purchase,
// and the checker is approving the account, so they need to see the whole subscription.
//
// Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so
// queryForList would key the maps "accountno" and "shareaccountid" — which serialise straight
// into the JSON and leave the client reading accountNo/shareAccountId as undefined.
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sa.id AS "shareAccountId",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.display_name AS "clientName",
o.name AS "officeName",
sp.name AS "productName",
sa.currency_code AS "currencyCode",
sa.submitted_date AS "submittedDate",
sa.resolution_number AS "resolutionNumber",
sa.resolution_date AS "resolutionDate",
au.username AS "submittedByUsername",
COALESCE(txn.requested_shares, 0) AS "requestedShares",
COALESCE(txn.requested_amount, 0) AS "requestedAmount"
FROM m_share_account sa
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
JOIN m_share_product sp ON sp.id = sa.product_id
LEFT JOIN m_appuser au ON au.id = sa.submitted_userid
LEFT JOIN (
SELECT t.account_id,
SUM(t.total_shares) AS requested_shares,
SUM(t.total_shares * t.unit_price) AS requested_amount
FROM m_share_account_transactions t
WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = 500
GROUP BY t.account_id
) txn ON txn.account_id = sa.id
WHERE sa.status_enum = ?
ORDER BY sa.submitted_date DESC, sa.id DESC
""", STATUS_SUBMITTED_AND_PENDING_APPROVAL, STATUS_SUBMITTED_AND_PENDING_APPROVAL);
return Response.ok(gson.toJson(rows)).build();
}
}
@@ -0,0 +1,196 @@
/**
* 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.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.Collections;
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.security.service.PlatformSecurityContext;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Additional-share tranches awaiting approval, across every share account, for a centralized Approval Center queue.
*
* <p>
* A tranche raised through "Apply Additional Shares" today is only visible for approval on its own account's page
* (the native {@code approveadditionalshares}/{@code rejectadditionalshares} commands, invoked directly from a button
* there) — there was no single place a checker could see every tranche waiting across the whole membership. This
* resource is the read side of that queue: one list of every pending tranche, and one detail view per tranche with
* everything a checker needs to decide, including the route-specific proof of funding a list row cannot show — which
* voucher(s) a cash tranche is resting on, or which savings account a savings tranche already debited.
*
* <p>
* Approval and rejection themselves are NOT duplicated here — they still go through Fineract's own
* {@code approveadditionalshares}/{@code rejectadditionalshares} commands (same as the account-page buttons), so the
* journal postings and {@link org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService}
* settle/unwind hooks that already run on those commands run exactly once, from exactly one code path.
*/
@Path("/v1/shareadditionrequests")
@Component
@Tag(name = "Share Addition Requests", description = "Additional-share tranches awaiting approval, across every share account.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareAdditionRequestsApiResource {
/** m_share_account_transactions.status_enum for "submitted, awaiting approval". */
private static final int STATUS_APPLIED = 100;
/** m_share_account_transactions.type_enum for a share purchase (an original application or a tranche alike). */
private static final int TYPE_PURCHASED = 500;
private static final String FUNDING_CASH = "CASH";
private static final String FUNDING_SAVINGS = "SAVINGS";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// Every alias is double-quoted: PostgreSQL folds an unquoted alias to lower case, so
// queryForList would key the maps "shareaccountid" etc., which serialise straight into JSON and
// leave the client reading shareAccountId as undefined.
private static final String BASE_SELECT = """
SELECT t.id AS "transactionId",
sa.id AS "shareAccountId",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.display_name AS "clientName",
o.name AS "officeName",
sp.name AS "productName",
sa.currency_code AS "currencyCode",
t.transaction_date AS "transactionDate",
t.total_shares AS "totalShares",
t.unit_price AS "unitPrice",
t.amount AS "amount",
t.charge_amount AS "chargeAmount",
t.resolution_number AS "resolutionNumber",
t.meeting_date AS "meetingDate",
t.funding_source AS "fundingSource",
t.advance_collection_id AS "advanceCollectionId",
t.additional_collection_ids AS "additionalCollectionIds"
FROM m_share_account_transactions t
JOIN m_share_account sa ON sa.id = t.account_id
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
JOIN m_share_product sp ON sp.id = sa.product_id
""";
@GET
@Operation(summary = "List additional-share tranches awaiting approval",
description = "Every pending tranche across every share account, with the member, account, shares and value a checker needs to see before deciding.")
public Response retrievePending() {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
BASE_SELECT + " WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = ? "
+ " ORDER BY t.transaction_date, t.id",
STATUS_APPLIED, TYPE_PURCHASED);
return Response.ok(gson.toJson(rows)).build();
}
@GET
@Path("/{transactionId}")
@Operation(summary = "Full detail for one pending tranche",
description = "The list row plus route-specific proof of funding: the voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.")
public Response retrieveOne(@PathParam("transactionId") final Long transactionId) {
this.context.authenticatedUser();
final Map<String, Object> row;
try {
row = this.jdbcTemplate.queryForMap(BASE_SELECT + " WHERE t.id = ?", transactionId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareaddition.not.found",
"Additional-share request " + transactionId + " does not exist.");
}
final Map<String, Object> out = new LinkedHashMap<>(row);
final String fundingSource = (String) row.get("fundingSource");
if (FUNDING_CASH.equals(fundingSource)) {
out.put("vouchers", vouchersFor(row));
} else if (FUNDING_SAVINGS.equals(fundingSource)) {
out.put("savingsAccount", savingsAccountFor(toLong(row.get("shareAccountId"))));
}
return Response.ok(gson.toJson(out)).build();
}
/**
* Every receipt funding a cash tranche — the lead voucher plus any further instalments — so a checker sees the
* whole pot behind the amount rather than just the first receipt.
*/
private List<Map<String, Object>> vouchersFor(final Map<String, Object> row) {
final List<Long> ids = new ArrayList<>();
final Long lead = toLong(row.get("advanceCollectionId"));
if (lead != null) {
ids.add(lead);
}
final String additional = (String) row.get("additionalCollectionIds");
if (additional != null && !additional.isBlank()) {
for (final String part : additional.split(",")) {
final String trimmed = part.trim();
if (!trimmed.isEmpty()) {
ids.add(Long.valueOf(trimmed));
}
}
}
if (ids.isEmpty()) {
return Collections.emptyList();
}
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
FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id
""".formatted(placeholders), ids.toArray());
}
/** The savings account a savings-route tranche already debited, so a checker can see it was real money. */
private Map<String, Object> savingsAccountFor(final Long shareAccountId) {
try {
return this.jdbcTemplate.queryForMap("""
SELECT sav.id, sav.account_no AS "accountNo", sav.account_balance_derived AS "balance"
FROM m_savings_account sav
JOIN m_share_account sa ON sa.savings_account_id = sav.id
WHERE sa.id = ?
""", shareAccountId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -301,10 +301,24 @@ public class ShareAccountData implements Serializable, AccountData {
minimumActivePeriodFrequencyTypeOption, currenMarketPrice);
}
/**
* Whether the member's money has actually been taken and the shares issued — either by applying a cash collection
* or by debiting their savings.
*
* Distinct from the account's own status: an account is Approved and then Activated before anything is settled, and
* the purchase transaction reads Approved from the moment the account is approved. Only this says money has moved,
* which is what the Transactions, Passbook and Charges views actually describe.
*/
private Boolean collectionApplied;
public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) {
this.currentMarketPrice = currentMarketPrice;
}
public void setCollectionApplied(final Boolean collectionApplied) {
this.collectionApplied = collectionApplied;
}
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
this.linkedLoanId = linkedLoanId;
this.linkedLoanAccountNo = linkedLoanAccountNo;
@@ -51,10 +51,23 @@ public class ShareAccountTransactionData implements Serializable {
private final BigDecimal amountPaid;
/** Board Resolution Number / Date of Meeting that authorised this transaction — populated
* only for a redeem (withdrawal) transaction; null otherwise. */
/** Board Resolution Number / Date of Meeting that authorised this transaction — recorded on a
* redeem (withdrawal) and on an additional-share purchase; null otherwise. */
private final String resolutionNumber;
private final LocalDate meetingDate;
/** How an additional-share tranche was paid for — {@code CASH} or {@code SAVINGS}; null on a
* first application, and on tranches raised before the route was captured. Shown to whoever
* approves the tranche, since approving is what releases the money out of Share Sundry. */
private final String fundingSource;
/** The lead voucher funding a CASH tranche; the rest are in {@link #additionalCollectionIds}. */
private final Long advanceCollectionId;
/** Further vouchers funding the same tranche, comma-separated; null when one receipt covered it. */
private final String additionalCollectionIds;
private final Long savingsDebitRequestId;
}
@@ -368,7 +368,7 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
}
public String getSavingsAccountNo() {
return this.savingsAccount.getAccountNumber();
return this.savingsAccount == null ? null : this.savingsAccount.getAccountNumber();
}
public void addAddtionalShares(Set<ShareAccountTransaction> additionalShares) {
@@ -121,10 +121,15 @@ public class ShareAccountCharge extends AbstractPersistableCustom<Long> {
this.amountOrPercentage = chargeAmount;
if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) {
this.percentage = null;
this.amount = BigDecimal.ZERO;
// A flat charge is owed in full the moment it is attached: its amount IS the
// configured fee, and all of it is outstanding until something pays it. Leaving
// both at zero (as this once did) made the entry fee invisible to every consumer
// that reads amount/amount_outstanding_derived — the share-collection settlement
// then asked the member for the share value alone and the fee went uncollected.
this.amount = chargeAmount;
this.amountPercentageAppliedTo = null;
this.amountPaid = null;
this.amountOutstanding = BigDecimal.ZERO;
this.amountOutstanding = calculateOutstanding();
this.amountWaived = null;
this.amountWrittenOff = null;
} else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) {
@@ -28,8 +28,11 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@@ -75,6 +78,36 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
@Column(name = "meeting_date")
private LocalDate meetingDate;
/**
* How this tranche was paid for — {@code CASH} or {@code SAVINGS}; null on tranches raised
* before the route was captured.
*
* Both routes park the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
* approving posts the same Dr Sundry / Cr Share Capital pair either way. The route matters on
* the way back out: rejecting a cash tranche releases the voucher to be spent again, while
* rejecting a savings tranche has to refund the member.
*/
@Column(name = "funding_source")
private String fundingSource;
/**
* The lead {@code m_share_advance_collection} receipt funding a CASH tranche.
*
* "Lead" because a member may pay one tranche in instalments; the rest are in
* {@link #additionalCollectionIds}. Read them together via {@link #getAllAdvanceCollectionIds()}
* rather than this field alone, or the later instalments are missed.
*/
@Column(name = "advance_collection_id")
private Long advanceCollectionId;
/** The remaining receipts funding this tranche, comma-separated; null when a single one covers it. */
@Column(name = "additional_collection_ids")
private String additionalCollectionIds;
/** The {@code m_share_savings_debit_request} behind a SAVINGS tranche. */
@Column(name = "savings_debit_request_id")
private Long savingsDebitRequestId;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
@@ -125,6 +158,79 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
return transaction;
}
/**
* Records the board resolution authorising a purchase, and how the tranche is funded.
*
* A purchase tranche carries its own resolution, not the account's: each further subscription
* is authorised by its own board meeting. Kept separate from the constructor because a first
* application has no funding route to record — only additional shares are raised this way.
*/
public void updatePurchaseAuthorisationAndFunding(final String resolutionNumber, final LocalDate meetingDate,
final String fundingSource, final List<Long> advanceCollectionIds, final Long savingsDebitRequestId) {
this.resolutionNumber = resolutionNumber;
this.meetingDate = meetingDate;
this.fundingSource = fundingSource;
this.savingsDebitRequestId = savingsDebitRequestId;
// First receipt leads; the rest ride along in a single column. Existing single-voucher rows
// keep exactly the shape they had, so nothing reading advance_collection_id has to change.
if (advanceCollectionIds == null || advanceCollectionIds.isEmpty()) {
this.advanceCollectionId = null;
this.additionalCollectionIds = null;
} else {
this.advanceCollectionId = advanceCollectionIds.get(0);
this.additionalCollectionIds = advanceCollectionIds.size() > 1
? advanceCollectionIds.subList(1, advanceCollectionIds.size()).stream().map(String::valueOf)
.collect(Collectors.joining(","))
: null;
}
}
public String getFundingSource() {
return this.fundingSource;
}
public Long getAdvanceCollectionId() {
return this.advanceCollectionId;
}
public String getAdditionalCollectionIds() {
return this.additionalCollectionIds;
}
/**
* Every receipt funding this tranche, lead first.
*
* The one method that should be used to act on a tranche's vouchers — reserving them, releasing
* them, marking them applied. Using {@code advanceCollectionId} alone would silently skip a
* member's later instalments, leaving their money reserved against nothing or spendable twice.
*/
public List<Long> getAllAdvanceCollectionIds() {
final List<Long> ids = new ArrayList<>();
if (this.advanceCollectionId != null) {
ids.add(this.advanceCollectionId);
}
if (this.additionalCollectionIds != null && !this.additionalCollectionIds.isBlank()) {
for (final String part : this.additionalCollectionIds.split(",")) {
final String trimmed = part.trim();
if (!trimmed.isEmpty()) {
final Long id = Long.valueOf(trimmed);
if (!ids.contains(id)) {
ids.add(id);
}
}
}
}
return ids;
}
public Long getSavingsDebitRequestId() {
return this.savingsDebitRequestId;
}
public void updateSavingsDebitRequestId(final Long savingsDebitRequestId) {
this.savingsDebitRequestId = savingsDebitRequestId;
}
public String getResolutionNumber() {
return this.resolutionNumber;
}
@@ -31,11 +31,10 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
* forfeited — the hold on the member's savings account is released and the amount is swept back
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
* the actual GL/balance mechanics.
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year release_due_date has passed with
* no Bank Manager action (via the Dividend Dashboard) is forfeited — the hold on the member's savings account is
* released and the amount is swept back into the bank's profit. See
* {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for the actual GL/balance mechanics.
*/
@Slf4j
@RequiredArgsConstructor
@@ -47,8 +46,13 @@ public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final List<Throwable> exceptions = new ArrayList<>();
// 'UNCLAIMED', not 'LOCKED': changelog 0461 reworked the lock from a savings hold into a pure
// "Dividend Payable" GL liability and renamed the initial status accordingly (see
// makeDividendsClaimable, which inserts 'UNCLAIMED'). forfeitDividendLock already guards on
// 'UNCLAIMED'; this query was the last place still looking for the old name, so the job silently
// selected zero rows and nothing was ever forfeited.
final List<Long> lockIds = this.jdbcTemplate.queryForList(
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
"SELECT id FROM m_share_dividend_lock WHERE status = 'UNCLAIMED' AND release_due_date <= CURRENT_DATE", Long.class);
for (final Long lockId : lockIds) {
try {
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.domain.PurchasedSharesStatusType;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountCharge;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy;
@@ -110,7 +111,9 @@ public class ShareAccountDataSerializer {
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname));
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname,
ShareAccountApiConstants.fundingsource_paramname, ShareAccountApiConstants.advancecollectionid_paramname,
ShareAccountApiConstants.additionalcollectionids_paramname));
@Autowired
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
@@ -155,8 +158,10 @@ public class ShareAccountDataSerializer {
baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank();
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull()
.longGreaterThanZero();
if (savingsAccountId != null) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId)
.longGreaterThanZero();
}
final Long requestedShares = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull()
.longGreaterThanZero();
@@ -197,12 +202,15 @@ public class ShareAccountDataSerializer {
}
Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId, DepositAccountType.SAVINGS_DEPOSIT,
shareProduct.getCurrency().getCode())) {
throw new SavingsAccountNotFoundException(savingsAccountId);
SavingsAccount savingsAccount = null;
if (savingsAccountId != null) {
if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId,
DepositAccountType.SAVINGS_DEPOSIT, shareProduct.getCurrency().getCode())) {
throw new SavingsAccountNotFoundException(savingsAccountId);
}
savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId,
DepositAccountType.SAVINGS_DEPOSIT);
}
SavingsAccount savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId,
DepositAccountType.SAVINGS_DEPOSIT);
final MonetaryCurrency currency = shareProduct.getCurrency();
Set<ShareAccountCharge> charges = assembleListOfAccountCharges(element, currency.getCode());
@@ -654,18 +662,24 @@ public class ShareAccountDataSerializer {
}
private void handlechargesOnActivation(final ShareAccount account) {
Set<ShareAccountCharge> charges = account.getCharges();
for (ShareAccountCharge charge : charges) {
if (charge.isActive() && charge.isShareAccountActivation()) {
charge.markAsFullyPaid();
}
}
// The entry fee is deliberately NOT marked paid here. Activating the account decides the
// member may hold shares; it does not collect anything, and nobody has yet said the cash
// receipt at the counter has actually been taken.
//
// Marking it paid at activation left amount_paid_derived at the full fee and
// amount_outstanding_derived at zero while nothing had actually been received, so the
// settlement step read the fee as already settled and asked the member for the share value
// alone ("shares 400.00 + charges 0"). The fee was then never collected at all.
//
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource,
// which sets amount_paid_derived and amount_outstanding_derived alongside the flag.
//
// The charge transaction is still dated to the activation date — that records when the fee
// became due, which is a separate thing from when it was paid.
Set<ShareAccountTransaction> transactions = account.getChargeTransactions();
for (ShareAccountTransaction transaction : transactions) {
if (transaction.isChargeTransaction()) {
transaction.updateTransactionDate(account.getActivatedDate());
transaction.updateAmountPaid(transaction.amount());
}
}
}
@@ -739,17 +753,165 @@ public class ShareAccountDataSerializer {
.failWithCodeNoParameterAddedToErrorCode("purchase.transaction.date.cannot.be.before.existing.transactions");
}
// The board resolution authorising this tranche. Its own meeting and number: a further
// subscription is a separate decision from the one the account was opened under, so it
// cannot inherit the account's resolution.
final String resolutionNumber = this.fromApiJsonHelper
.extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank();
final LocalDate meetingDate = this.fromApiJsonHelper
.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull();
final String fundingSource = this.fromApiJsonHelper.extractStringNamed(ShareAccountApiConstants.fundingsource_paramname, element);
// The List overload, not the varargs one: the varargs version lowercases the incoming value
// but compares it against the un-lowercased allowed list, so an upper-case value can never
// match it. The List overload lowercases both sides.
baseDataValidator.reset().parameter(ShareAccountApiConstants.fundingsource_paramname).value(fundingSource).notBlank()
.isOneOfTheseStringValues(List.of(ShareAccountApiConstants.FUNDING_SOURCE_CASH,
ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS));
// On the cash route the money is already in Share Sundry against receipts the cashier
// issued, so the tranche has to name them. Validated here rather than at approval: a voucher
// problem found later is found once the member has gone.
//
// A list, not one id: a member often pays a tranche in instalments, and no single receipt
// covers it. They are validated as one settlement, so their combined total is what has to
// cover the shares.
List<Long> advanceCollectionIds = List.of();
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
advanceCollectionIds = extractAdvanceCollectionIds(element);
if (advanceCollectionIds.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull();
} else {
validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator);
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
final BigDecimal unitPrice = shareProduct.deriveMarketPrice(requestedDate);
ShareAccountTransaction purchaseTransaction = new ShareAccountTransaction(requestedDate, sharesRequested, unitPrice);
purchaseTransaction.updatePurchaseAuthorisationAndFunding(resolutionNumber, meetingDate, fundingSource, advanceCollectionIds, null);
account.addAdditionalPurchasedShares(purchaseTransaction);
handleAdditionalSharesChargeTransactions(account, purchaseTransaction);
actualChanges.put(ShareAccountApiConstants.additionalshares_paramname, purchaseTransaction);
return actualChanges;
}
/**
* The receipts a cash-funded tranche names — the lead one plus any further instalments.
*
* Accepts either shape, so a single-voucher caller need not send a list: {@code advanceCollectionId}
* alone, or that plus {@code additionalCollectionIds}.
*/
private List<Long> extractAdvanceCollectionIds(final JsonElement element) {
final List<Long> ids = new ArrayList<>();
final Long leadId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.advancecollectionid_paramname, element);
if (leadId != null) {
ids.add(leadId);
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.additionalcollectionids_paramname, element)) {
final JsonArray array = this.fromApiJsonHelper
.extractJsonArrayNamed(ShareAccountApiConstants.additionalcollectionids_paramname, element);
if (array != null) {
for (final JsonElement arrayElement : array) {
if (arrayElement != null && !arrayElement.isJsonNull()) {
final Long id = arrayElement.getAsLong();
if (!ids.contains(id)) {
ids.add(id);
}
}
}
}
}
return ids;
}
/**
* Checks the Share Sundry receipts a cash-funded tranche names.
*
* Each receipt is checked on its own for the things that make it unusable — it does not exist,
* it belongs to somebody else, it has already been spent, or it is reserved by another pending
* tranche. That last check matters most: without it the same voucher could fund two tranches, so
* Share Sundry would be credited once and released twice.
*
* The value check is the one thing judged across all of them together, because that is how a
* member pays in instalments: ₹600 today and ₹400 tomorrow fund a ₹1,000 tranche, though neither
* receipt covers it alone.
*/
private void validateAdvanceCollectionsForTranche(final List<Long> advanceCollectionIds, final ShareAccount account,
final Long sharesRequested, final LocalDate requestedDate, final DataValidatorBuilder baseDataValidator) {
BigDecimal receiptTotal = BigDecimal.ZERO;
for (final Long advanceCollectionId : advanceCollectionIds) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT receipt_no, voucher_no, client_id, amount, status FROM m_share_advance_collection WHERE id = ?",
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.");
continue;
}
final Map<String, Object> collection = rows.get(0);
// Named in every message below, because with several receipts in play "the voucher is
// 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.");
}
// 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",
"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",
"Voucher " + label + " is already reserved against another pending share request.");
}
final BigDecimal receiptAmount = (BigDecimal) collection.get("amount");
if (receiptAmount != null) {
receiptTotal = receiptTotal.add(receiptAmount);
}
}
final BigDecimal trancheValue = account.getShareProduct().deriveMarketPrice(requestedDate)
.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
+ ", which does not cover the " + trancheValue + " value of the shares requested.");
}
}
/**
* Whether this receipt is already earmarked by a pending tranche.
*
* Checks the lead column and the instalment list, because a receipt reserved as somebody's
* second instalment is just as spent as one reserved as a lead.
*/
private boolean isReservedByAnotherTranche(final Long advanceCollectionId) {
final Long count = this.jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_share_account_transactions
WHERE is_active = true AND status_enum = ?
AND (advance_collection_id = ?
OR string_to_array(COALESCE(additional_collection_ids, ''), ',') @> ARRAY[CAST(? AS text)])
""", Long.class, PurchasedSharesStatusType.APPLIED.getValue(), advanceCollectionId, String.valueOf(advanceCollectionId));
return count != null && count > 0;
}
private boolean isTransactionBeforeExistingTransactions(LocalDate requestedDate, boolean isTransactionBeforeExistingTransactions,
ShareAccount shareAccount) {
Collection<ShareAccountTransaction> activeTransactions = shareAccount.getShareAccountTransactions().stream()
@@ -0,0 +1,474 @@
/**
* 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.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
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 lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
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.
*
* <p>
* <b>Why this is separate from the account-level settlement resource.</b> {@code ShareAdvanceCollectionApiResource}
* settles an account's <i>first</i> application. It values the settlement by summing every purchase row on the
* account, and guards with account-wide state — {@code collection_applied} — that assumption does not survive a
* second tranche: the valuation would charge the member for their entire holding history. So tranche funding is
* 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.
*
* <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:
*
* <pre>
* Dr Savings Control (tranche value)
* Cr Share Sundry (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>
* </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.
*/
@Component
@RequiredArgsConstructor
public class AdditionalShareTrancheFundingService {
/** Fineract convention: 1 = CREDIT, 2 = DEBIT. */
private static final int CREDIT = 1;
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 PRODUCT_TYPE_SHARES = 4;
/** 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.
*
* Called after the tranche has been saved and flushed, so it has an id to record against, and
* within the apply command's transaction — a tranche that cannot be funded must not exist.
*/
public void fund(final ShareAccount account, final ShareAccountTransaction transaction) {
final String fundingSource = transaction.getFundingSource();
if (fundingSource == null) {
return;
}
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
reserveAdvanceCollection(account, transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
debitSavingsIntoSundry(account, transaction);
}
}
/**
* Settles a tranche's funding once the tranche has been approved and its value released from
* Share Sundry into share capital.
*
* 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.
*/
public void settle(final ShareAccount account, final ShareAccountTransaction transaction) {
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
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.
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
this.jdbcTemplate.update("""
UPDATE m_share_advance_collection
SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ?
WHERE id = ? AND status = 'UNAPPLIED'
""", account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), 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.
*
* 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:
*
* <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>
* </ul>
*/
public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) {
final String fundingSource = transaction.getFundingSource();
if (fundingSource == null) {
return;
}
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
releaseAdvanceCollection(transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
refundSavings(account, transaction);
}
}
/**
* Lets the voucher be used again. The receipt itself was never marked APPLIED — approval is
* what would have done that — so only the account it was earmarked against is cleared.
*/
private void releaseAdvanceCollection(final ShareAccountTransaction transaction) {
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = NULL WHERE id = ?", collectionId);
}
}
/**
* 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.
*
* 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.
*/
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 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);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId();
}
/**
* Cash route: the money is already in Sundry, so this only ties the receipt to this tranche.
*
* The receipt is left UNAPPLIED. It is not share capital yet — approval is what releases it —
* and marking it APPLIED now would say the money had already become capital. The tranche's own
* {@code advance_collection_id} is what stops it funding a second tranche, checked when the
* next request is validated.
*/
private void reserveAdvanceCollection(final ShareAccount account, final ShareAccountTransaction transaction) {
final List<Long> collectionIds = transaction.getAllAdvanceCollectionIds();
if (collectionIds.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.cash.without.voucher",
"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());
for (final Long collectionId : collectionIds) {
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(),
collectionId);
}
}
/**
* Savings route: debits the member and credits Share Sundry.
*/
private void debitSavingsIntoSundry(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.");
}
final Long savingsAccountId = account.getSavingsAccount().getId();
final BigDecimal amount = transaction.amount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.amount.invalid",
"The value of the shares requested could not be determined.");
}
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active",
"Savings account " + savings.get("account_no") + " is not active.");
}
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
if (balance == null || balance.compareTo(amount) < 0) {
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) {
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) {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, status_enum, account_balance_derived, product_id FROM m_savings_account WHERE id = ?",
savingsAccountId);
}
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
try {
final Long glId = this.jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
} catch (final IncorrectResultSizeDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
}
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) {
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, "
+ " 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,
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. The instant has to be resolved in the tenant's own
* zone first, which is the zone {@code now} was taken in.
*/
private OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC);
}
private static Long toLong(final Object value) {
return value == null ? null : ((Number) value).longValue();
}
private static int toInt(final Object value) {
return value == null ? 0 : ((Number) value).intValue();
}
}
@@ -50,7 +50,9 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
StringBuilder buff = new StringBuilder()
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate")
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ")
.append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ")
.append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId")
.append(" from m_share_account_transactions saps ");
schema = buff.toString();
}
@@ -72,9 +74,14 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
final String resolutionNumber = rs.getString("resolutionNumber");
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
final String fundingSource = rs.getString("fundingSource");
final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId");
final String additionalCollectionIds = rs.getString("additionalCollectionIds");
final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId");
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId,
additionalCollectionIds, savingsDebitRequestId);
}
public String schema() {
@@ -140,6 +140,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false);
final BigDecimal currentMarketPrice = deriveMarketPrice(productData);
data.setCurrentMarketPrice(currentMarketPrice);
// Read separately rather than added to the row mapper, which is shared with the list and
// template queries that have no use for it.
data.setCollectionApplied(this.jdbcTemplate.queryForObject("SELECT COALESCE(collection_applied, false) FROM m_share_account "
+ " WHERE id = ?", Boolean.class, id));
if (!includeTemplate) {
Collection<ShareAccountDividendData> dividends = this.retrieveAssociatedDividends(id);
data.setDividends(dividends);
@@ -467,7 +471,9 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
StringBuilder buff = new StringBuilder().append(
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate ");
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ")
.append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ")
.append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId ");
schema = buff.toString();
}
@@ -489,8 +495,13 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final String resolutionNumber = rs.getString("resolutionNumber");
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
final String fundingSource = rs.getString("fundingSource");
final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId");
final String additionalCollectionIds = rs.getString("additionalCollectionIds");
final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId");
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId,
additionalCollectionIds, savingsDebitRequestId);
}
public String schema() {
@@ -40,7 +40,13 @@ import org.springframework.transaction.annotation.Transactional;
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
/** Retained profit, where a dividend nobody claimed returns to. */
private static final String PROFIT_GL_CODE = "BANKPROFIT01";
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private final ShareAccountDividendRepository shareAccountDividendRepository;
private final SavingsAccountDomainService savingsAccountDomainService;
@@ -62,12 +68,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
}
/**
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
* account up front -- it stays purely a "Dividend Payable" GL liability (see
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
* dividend past its 3-year deadline has no savings hold to release and no account balance to
* deduct -- the liability is simply written off directly against the bank's profit.
* Real-world UCB accounting: an approved dividend is never credited into a member's savings account up front -- it
* stays purely a "Dividend Payable" GL liability (see
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the member actually
* claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed dividend past its 3-year deadline has no
* savings hold to release and no account balance to deduct -- the liability is simply written off directly against
* the bank's profit.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -84,27 +90,54 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
postForfeitureJournalEntry(lockId, currencyCode, amount);
this.jdbcTemplate.update(
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
}
/**
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
* Reads currency straight off the lock row (set from the share account at creation time) --
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
* both GL accounts yet, exactly like the declaration-time Reserve entry.
* The financial year of the declaration a lock came from. "unknown" rather than an exception if the chain is
* broken — a missing label must not stop the forfeiture from posting.
*/
private String financialYearOfLock(final Long lockId) {
final java.util.List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT d.dividend_period_end_date
FROM m_share_dividend_lock l
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
WHERE l.id = ?
""", lockId);
if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
return "unknown";
}
final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/**
* Writes off a dividend nobody came to claim.
*
* <pre>
* Dr Dividend Payable — the obligation ends; it was never going to be paid
* Cr Bank Profit
* </pre>
*
* Credited straight to Bank Profit rather than routed through an income account: the money never left the society,
* so this is a liability that turned out not to be owed, and it returns directly to the retained profit the
* dividend was declared out of.
*
* Currency comes straight off the lock row (set from the share account when the lock was created), so this never
* depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the
* declaration-time entries.
*/
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
final Long payableDividendsAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long forfeitedIncomeAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
final Long bankProfitAccountId = this.jdbcTemplate
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
.orElse(null);
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
return;
}
@@ -112,7 +145,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVFORFEIT" + lockId;
final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
// 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.
final String description = "FY " + financialYearOfLock(lockId) + " dividend forfeited to profit — share dividend lock #" + lockId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -120,10 +156,11 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
// Debit first, then credit, so the entry ids follow the direction the money travelled.
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, bankProfitAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
}

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