Compare commits

...
16 Commits
Author SHA1 Message Date
nitesh af43944699 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-14 12:17:01 +05:30
keshav 39fa100c70 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
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 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 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 Documentation build / build (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-14 11:46:43 +05:30
keshav 1c6ac9d77f feat: AML direction/entity scoping, Sponsor DD revoke workflow, Dividend GL resolution & TDS module 2026-09-14 11:46:16 +05:30
niteshandClaude Sonnet 5 6bb512e1fe fix(cash): recognize GL-entry cash correctly in reconciliation and reports
- Add CountedCashLedger: one shared rule for which m_savings_txn_denomination
  rows are real counted cash. Replaces a magnitude-only guard (which had
  itself been silently excluding legitimate manual-GL-entry cash from every
  cash screen) with a date-plausibility test -- a stamped garbage id decodes
  back to the day it was written, a real GL reference to an arbitrary date --
  so a hex reference that happens to land in the old guard's excluded band is
  no longer misclassified as timestamp garbage
- Apply it consistently in the day register close preview, the cashier
  position/day-book reports, the cash reconciliation screen, and the
  withdrawal stock check, so all four agree on the same drawer
- GL Entries report: join m_voucher_line to show both legs of each voucher
  (debit and credit account) instead of only the cash leg its own
  denomination row happens to carry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:19:05 +05:30
niteshandClaude Sonnet 5 04ac233fbe fix: share collection GL lookup, tenant chart-of-accounts cleanup, and dormancy/date fixes
- Add balancesheet API, GL account transaction guard, and disabled-feature filter
- Recognize an approved Clerk Disbursement Request (COLLECTION direction) as a
  second valid source when resolving a Share Advance Collection voucher, not
  just a Cashier Transaction Entry deposit
- Fix a NullPointerException in ShareAccount.getShareAccountTransaction() when
  an existing charge transaction has no total-shares/purchase-price recorded
- Null out DormancyClock's "last live" date before an account is ever
  activated, instead of falling through to the client's own KYC re-verification
  date via Postgres's null-skipping GREATEST()
- Stop seeding "Cash at Tellers" for new tenants (0502); drop the unused
  Facility & Service Charges Income accounts (0548); group header-less GL
  accounts under real-world header accounts (0549); uppercase GL account
  names and rename Share Sundry to match the bank's own chart (0550)
- Fix 0527's savings-daily-interest changeset to skip objects 0517 already
  creates on a fresh tenant, instead of failing tenant startup
- Update the local Postgres dev password default to match this environment

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:22:07 +05:30
nitesh 9e0c04583d fix(roles): remove unused roles, merge managers, revoke Clerk's ALL_FUNCTIONS
Audited role usage/permissions across all tenants and cleaned up several
inconsistencies:

- Removed 'Self Service User' (zero users, no wired-up purpose in the app).
- Merged 'As Manager' into 'Branch Manager' (union of both permission sets,
  users reassigned, As Manager dropped) since the two covered overlapping
  ground with no clear split.
- Revoked Clerk's ALL_FUNCTIONS grant: it unconditionally bypassed every
  individual permission check (HasPermissionDirective), so unticking any
  menu/permission for Clerk in Roles & Permissions had no effect. Clerk's
  own explicit permission rows are untouched and remain the effective set.
- Renamed 'CASHIER' to 'Cashier' for display consistency with every other
  role name; the role-name check this touches (CashierRoleHelper) is
  case-insensitive, so behavior is unchanged.

('Principal Officer' was audited too but kept - it's the statutory
PMLA/RBI compliance seat from 0435, just not yet assigned to anyone.)

Each change was applied directly to all three tenant DBs and captured as
a migration (0543, 0546, 0547), dry-run verified as a no-op against the
now-updated DBs so it applies cleanly here and correctly for any new tenant.
2026-09-12 13:54:56 +05:30
nitesh c20a7d0728 feat(security): tenant-level nav menu access control, independent of role permissions
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (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 - 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 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 Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 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 - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract 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
Adds a per-tenant ceiling on which of the 27 top-nav sections a bank can ever
show, closing a real gap the "Clerk" role's ALL_FUNCTIONS misconfiguration
exposed this session: HasPermissionDirective already hid a menu a user's own
role lacked, but any role carrying ALL_FUNCTIONS bypassed that check
unconditionally, so a bank's own super user (or a role accidentally granted
ALL_FUNCTIONS) could always see everything regardless of intent.

New table m_tenant_menu_access (0541), one row per menu code, seeded enabled
for every existing tenant so nothing already deployed changes behaviour.

New TenantMenuAccessApiResource:
- GET /v1/tenant-menu-access — dual-mode (session or X-Api-Key), same posture
  as ClientAllAccountsApiResource. A tenant's own users read their own list
  to render their nav; the platform operator reads any tenant's list with
  the key, to know current state before toggling it.
- GET /v1/tenant-menu-access/tenants — API key only, the master tenant list
  for the operator's own tenant picker.
- PUT /v1/tenant-menu-access — API key only, no session fallback at all, even
  for a super user. This is the actual security boundary: a bank's own admin
  has no path to lift its own ceiling, because there is nothing inside their
  own tenant that can satisfy this endpoint's auth requirement.

0542 splits two nav items that had been sharing a permission code with their
neighbour — Loan Origination rode on MENU_ADVANCES, Investment rode on
MENU_RBI — into their own MENU_LOAN_ORIGINATION / MENU_INVESTMENT codes, so
all 27 top-nav items are now independently controllable. Every role that
already held the shared code is granted the new one in the same migration,
so this changes nothing about who sees what today.

SecurityConfig: permitAll for the two API-key-only paths, same posture as
the existing clientaccounts carve-out — the handler itself is what actually
authenticates the caller, not Spring Security's outer gate.
2026-09-11 15:07:13 +05:30
keshav 3d196276c8 feat(savings): support rounding adjustment GL mapping and floor rounding on clerk closure
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-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 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 Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (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, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (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 (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
2026-09-11 10:48:50 +05:30
keshav 5cc5d680f2 feat: separate kyc/aml risk rules, dormancy clock, and savings daily interest fixes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract 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 Docker Builds / build (true, docker-compose.yml, mariadb) (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 6 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 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 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-10 18:27:50 +05:30
nitesh 6aa655a8a3 fix(cash): correct branch-day reconciliation, voucher classification and duplicate posting bugs
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (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 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
BranchDayRegisterApiResource:
- Make openDay/closeDay transactional and add an atomic closed_at guard so a
  double-close or a mid-write failure cannot leave the register half-updated.
- Fix bookBreakdown()'s slip/closure withdrawal queries: m_clerk_withdrawal_request
  and m_clerk_closure_request store cashier_id as an appuser id in some registers
  and an m_cashiers id in others; try both via a dual EXISTS instead of assuming
  one, which was silently dropping real withdrawals from expected cash and
  producing a false "Short" on close.
- Office-scope vault-in/settled-out and close-preview queries so a multi-office
  tenant's close no longer mixes another branch's cash into this one's.
- closeDay requires closingCash and uses DateUtils.getBusinessLocalDate() for
  late-close detection instead of the server's own LocalDate.now().
- saveDenominationDetail() now deletes any prior snapshot for the same
  register/leg/teller before inserting, so a repeated save no longer sums
  duplicate denomination rows into the vault position.
- resolveOffice() enforces office-hierarchy authorization via
  PlatformSecurityContext.validateAccessRights().

CashReconciliationApiResource / CashReportApiResource / DenominationBreakdownWriter:
- Add a MAX_PLAUSIBLE_TXN_ID filter (consistent with the existing cash report
  guard) so a fabricated/oversized transaction id can no longer distort a
  cashier's expected or counted position.
- Tolerate sub-rupee (paisa) rounding differences instead of requiring an exact
  amount match — Indian cash has no paisa coins, so exact-equality was
  rejecting correctly-counted cash.
- CashReconciliationApiResource now trusts its own server-computed expected
  count rather than the client-submitted one when recording a reconciliation.

VaultPositionService:
- Only fall back to the branch-total OPENING leg when the office has exactly
  one teller; a multi-teller branch was getting one teller's vault entry
  applied to all of them.

CashSettlementApiResource / CashInterCashierApiResource / CashExchangeApiResource:
- Make settlement creation transactional; add a partial unique index
  (0540) blocking two active settlements for the same cashier/day, closing a
  double-submit race the application-level count check alone could not.
- Validate a cash transfer against the sending cashier's actual denomination
  holdings and require both cashiers to be in the same office.
- Default cash exchange date to the business date instead of null, and add the
  missing CREATE/REVERSE permission checks (0539 seeds them).

CashierDepositRequestApiResource:
- Namespace pending-deposit denomination rows with a +500,000,000 offset so
  they cannot collide with real transaction ids during approve/reject.

ClerkDisbursementRequestApiResource (Disbursement/Collection/Fund Transfer/Bank
Transfer register):
- executeSavingsWithdrawal() was missing the withOwnVoucher() suppression its
  sibling methods already had, so every Collection-mode approval raised a
  second, orphaned voucher for the same money on top of the request's own —
  double-counting the figure in Received and Payments on My Vouchers.
- Voucher numbering no longer hard-codes CR/DR by money direction. It now
  checks whether the payment type the clerk actually selected is cash
  (m_payment_type.is_cash_payment) and numbers into the Journal series when it
  is not, since a Transfer/Cheque/DD instrument never crosses the counter
  regardless of which GL it posts against. Bank Transfer vouchers (always
  account-to-account, never cash) are now unconditionally Journal too.
- The voucher's TYPE column stopped being hard-coded to "DISBURSEMENT" for
  every request this register raises; it now stores the clerk's own request
  type (Disbursement/Collection/Fund Transfer/Bank Transfer) so My Vouchers
  shows what was actually submitted.

VoucherVerificationApiResource (/my-work):
- Classify a voucher's side (Received/Payment/Journal) by whether either of
  its lines actually touches the till (Cash in Hand or a cash-at-vault/teller
  GL), the same test the register listing already uses, instead of trusting
  the voucher number's own CR/DR prefix at face value. A Collection or
  Disbursement posted against a plain GL (no cash involved) was being counted
  as real cash received/paid; it now reads as Journal, matching what actually
  happened at the counter. Existing CR/DR requests with no lines yet are left
  alone, since there is nothing to test the till against.
2026-09-10 12:36:38 +05:30
niteshandClaude Opus 5 518b4d4afc feat(client-accounts): active accounts only, drop balances/shares, add branch and bank details
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract 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
Adds branch pincode (new column + API + migration 0538) since it was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:00:12 +05:30
niteshandClaude Sonnet 5 05bceea2d4 feat(office): maker-checker delete for offices; fix DB password leak, migration ordering, job id drift
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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract 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 - 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 - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract 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
Adds the missing DELETE /offices/{officeId} endpoint -- the UI's "Remove"
action called it already, but no backend route existed, so every attempt
failed with a generic error regardless of how empty the office was. Wired
the standard command-handler chain (CommandWrapperBuilder.deleteOffice ->
DeleteOfficeCommandHandler -> OfficeWritePlatformService.deleteOffice),
which pre-checks sub-offices/staff/clients/groups/users before deleting,
and catches any remaining FK violation on delete itself. The new
DELETE_OFFICE permission (0537) is seeded maker-checker-enabled (0538),
since this app runs with the global maker-checker switch on and office
deletion is irreversible -- 0538 is a separate changeset rather than
0537 itself because 0537 had already executed on local tenants by the
time that requirement surfaced, and editing an already-run changeset's
content breaks Liquibase's checksum validation on every tenant that ran it.

Also bundles a few DB/startup fixes found while getting this feature
running across local and a remote environment:
- application.properties: restores the tenant DB password default that a
  teammate's own local password had overwritten in a previous push, and
  adds server.error.include-message=always so a servlet-level rejection
  (e.g. TenantAwareBasicAuthenticationFilter on a missing/invalid
  Fineract-Platform-TenantId header) carries a real message instead of a
  bare {timestamp,status,error,path}.
- changelog-tenant.xml / final-changelog-tenant.xml: moves the
  0517/0518 table-creation includes ahead of 0525-0527/0533, which
  read/write those tables but previously ran first per
  db.changelog-master.xml's include order -- caused "relation ... does
  not exist" on any tenant reaching that point for the first time.
- 0536_sync_job_identity_sequence.xml: job.id is GENERATED BY DEFAULT AS
  IDENTITY; explicit-id job-seed inserts never advanced the backing
  sequence, so a later identity-generated insert could collide with an
  earlier explicit id ("duplicate key value violates ... job_pkey").
- ClientAllAccountsApiResource: NotFoundException(String) only sets the
  exception's own message for logs, it never reaches the response body,
  so a "client not found" 404 always came back empty; now built as a
  proper JSON error body. Also collapsed the message/defaultUserMessage/
  errors[0].defaultUserMessage triple down to one message field -- this
  endpoint never raises more than one error at a time, so the repetition
  added nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:16:07 +05:30
keshav e33e0f23fe feat: cashier reports, daily interest enhancements and business date sync
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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
2026-09-08 10:45:04 +05:30
nitesh 317a308d8c feat(client): add fast client-accounts lookup by client number or mobile number
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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
GET /v1/clientaccounts/{identifier} -- a single client's full deposit + share
picture (Savings, Fixed Deposit, Recurring Deposit, Current, Share), looked
up by client number or mobile number rather than the internal client id, so
a teller with only the slip in front of them (or a third-party caller with
only a mobile number) needs no separate client-search step first. Meant to
answer "is there a problem here" (e.g. a share purchase that would exceed a
product's issuable shares) BEFORE a manager approves something, not after.

Deliberately its own lightweight resource rather than a reuse of the
existing GET /clients/{clientId}/accounts (AccountSummaryCollectionData):
that endpoint's row mappers each carry six LEFT JOINs to m_appuser for a
full submitted/approved/rejected/activated/closed audit trail per account,
which this caller doesn't need. One UNION ALL query (SB/FD/RD from
m_savings_account split by deposit_type_enum, CD from its own
m_current_account table, Share from m_share_account), each leg filtering on
an indexed client_id -- fewer joins and one round trip is what actually
makes this fast, not a different query engine.

Identifier resolution tries m_client.account_no first (what a teller has),
then mobile_no (what a third-party caller more often has) -- both unique
indexed columns, so neither path costs more than a single index lookup.

Reachable two ways: an ordinary logged-in Fineract user exactly as with any
other endpoint, or a third-party caller via the new X-Api-Key mechanism
(see the ApiClientCredentialService commit) -- one key now valid across
every tenant, so the response echoes back tenantId/tenantName and the
identifier that was searched, and a not-found error names the bank actually
searched. Without that, "no client found" from the wrong bank (a stale or
mistyped Fineract-Platform-TenantId header) was indistinguishable from a
genuinely unknown client.

Every date field is serialized as a plain ISO string, never a raw
java.time.LocalDate: this Gson instance has no java.time adapter registered,
and reflectively touching LocalDate's private fields is blocked outright by
the JVM module system (java.base does not open java.time to an unnamed
module) -- passing one through untouched turns the whole response into a
500.
2026-09-07 18:38:09 +05:30
nitesh a83806062e feat(security): add cross-tenant API key auth for third-party callers
Endpoints that need to be reachable by a third-party integration without a
Fineract username/password used to have no supported way to do that other
than the heavyweight IMPS/NPCI pattern (RSA-signed JWT envelopes, built for
one specific bank-switch protocol). This adds a general-purpose one: mint an
X-Api-Key credential, hand it to the partner, done.

New table m_api_client lives in the tenant-STORE database (fineract_tenants),
not any one bank's own tenant database. That is deliberate: a key identifies
a CALLER, independent of which bank's data a given request asks for, so one
minted key keeps working against every tenant that exists now and every one
provisioned later -- a per-tenant copy would need a manual re-insert on every
new bank and a row per tenant per key, which is exactly what storing it once,
centrally, avoids. Fineract-Platform-TenantId keeps doing what it always
did: routes the rest of a request to the right bank's database. This table
only decides who is allowed to make the request at all.

ApiClientCredentialService.authenticate() is what an endpoint calls in place
of PlatformSecurityContext#authenticatedUser() when it sees an X-Api-Key
header. Security properties:
- Only a SHA-256 hash of the key is ever stored -- same reason Fineract
  never stores a user's plaintext password. The raw key is returned exactly
  once, at creation (POST /v1/apiclients), and cannot be retrieved again.
- Every failure path (key doesn't exist, is deactivated, caller's IP isn't
  on its allowlist) raises the identical 401 message, so a probing caller
  can't use the response to tell a wrong key apart from a valid-but-blocked
  one.
- allowedIps is a plain comma-separated exact-match list, adequate for a
  small set of known partner integrations, not CIDR ranges.

ApiClientAdminApiResource (create/list/deactivate/reactivate a key) is
itself gated by an ordinary Fineract login restricted to a super user
(ALL_FUNCTIONS) -- issuing a credential that skips login is not something to
hand out casually.

SecurityConfig gets one new permitAll() matcher, narrowly scoped to
GET /api/*/clientaccounts/** -- the same posture this fork already uses for
the IMPS and NPCI receivers: the outer Spring Security gate stays open for
one specific path, and the resource itself is what actually authenticates
the caller. A normal logged-in SPA user hitting that same path is
unaffected -- Basic Auth still runs for any request that carries an
Authorization header, permitAll only stops an anonymous one from being
rejected before the handler gets a chance to check for a key instead.
2026-09-07 18:38:08 +05:30
keshavandClaude Opus 5 e4cc2e558e cash-only registers, day book sections and DD amendments
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / 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 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-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
Cashier reports now reconcile against the drawer:
- Received/Payment exclude what cannot be cash rather than admitting
  only proven cash, so registers that pay out without recording a
  denomination breakdown are no longer silently dropped
- Closing report scoped to the cashier, with opening/closing balance,
  reversed counter transactions excluded, and cash DD receipts counted
- Day book split into Cashier and Clerk sections, filed by whose cash
  moved rather than who raised the request

Fixed one deposit or withdrawal raising two vouchers: the cashier
deposit and clerk withdrawal registers did not suppress the inner
savings command's voucher, doubling the day's cash on both reports.

Sponsor DD:
- Amendment request flow (beneficiary, payable-at branch and city)
  under maker-checker; face value and funding stay unamendable
- Cash DD denominations were collected but never saved, and the amount
  check demanded a figure notes could not add to; both fixed and the
  breakdown is now required

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:45:36 +05:30
209 changed files with 19959 additions and 6961 deletions
+68
View File
@@ -0,0 +1,68 @@
-- Removes the duplicate vouchers left behind by the double-count bug.
--
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
-- and Payment reports counted both, doubling the day's cash.
--
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
--
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
-- that link and orphan the request row.
--
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
BEGIN;
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
-- genuinely separate transactions of the same value on the same day are never confused.
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
SELECT sav.voucher_no
FROM m_voucher sav
JOIN m_voucher reg
ON reg.savings_txn_id = sav.reference_id
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
-- rather than removed silently.
DO $$
DECLARE
blocked INT;
BEGIN
SELECT COUNT(*) INTO blocked
FROM (SELECT voucher_no FROM m_voucher_verification
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
UNION ALL
SELECT voucher_no FROM m_voucher_attachment
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
IF blocked > 0 THEN
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
END IF;
END $$;
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
DO $$
DECLARE
stamped INT;
BEGIN
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
IF stamped > 0 THEN
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
END IF;
END $$;
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
COMMIT;
@@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1) (SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0); WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
INSERT IGNORE INTO m_charge
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
charge_calculation_enum, is_penalty, is_active, is_deleted,
is_free_withdrawal, is_payment_type, income_or_liability_account_id)
SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0,
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0);
INSERT IGNORE INTO m_charge INSERT IGNORE INTO m_charge
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum, (name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
charge_calculation_enum, is_penalty, is_active, is_deleted, charge_calculation_enum, is_penalty, is_active, is_deleted,
@@ -168,6 +160,5 @@ VALUES
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)), ('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)), ('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)), ('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)),
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)), ('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1)); ('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
-4
View File
@@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL; ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
@@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id); ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
@@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL; ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
@@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id); ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
*/ */
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues(); private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
/**
* Activities an administrator may map from the Financial Activity Mappings screen.
*
* Held in one place because create and update both validate against it, and the two lists had already drifted -
* update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even
* though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be
* seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable.
*/
private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(),
FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(),
FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(),
FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(),
FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(),
FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() };
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue(); private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue(); private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
final JsonElement element = this.fromApiJsonHelper.parse(json); final JsonElement element = this.fromApiJsonHelper.parse(json);
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element); final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues( baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(), .isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(),
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element); final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero(); baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) { if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
element); element);
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues( baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(), .isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
} }
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) { if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, // DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
GL_ACCOUNT_DISABLED, // GL_ACCOUNT_DISABLED, //
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, // GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
GL_ACCOUNT_IS_HEADER, //
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; // INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
public String errorMessage() { public String errorMessage() {
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "Invalid debit or credit accounts are passed"; return "Invalid debit or credit accounts are passed";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) { } else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
return "Target account does not allow manual adjustments"; return "Target account does not allow manual adjustments";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly";
} }
return name(); return name();
} }
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts"; return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) { } else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted"; return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
return "error.msg.glJournalEntry.invalid.account.is.header";
} }
return name(); return name();
} }
@@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId) baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
.notNull().integerGreaterThanZero(); .notNull().integerGreaterThanZero();
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper // Transfers In Suspense is not offered by every savings product UI, so it is validated only
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); // when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
}
final Long interestOnSavingsAccountId = this.fromApiJsonHelper final Long interestOnSavingsAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element); .extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
@@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
} }
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) { if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
final Long overdraftAccount = this.fromApiJsonHelper // Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); // only, so they are validated when supplied rather than demanded from every savings product.
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
.value(overdraftAccount).notNull().integerGreaterThanZero(); element)) {
final Long overdraftAccount = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
.value(overdraftAccount).notNull().integerGreaterThanZero();
}
final Long incomeFromInterest = this.fromApiJsonHelper if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); final Long incomeFromInterest = this.fromApiJsonHelper
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest) .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
.notNull().integerGreaterThanZero(); baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
.value(incomeFromInterest).notNull().integerGreaterThanZero();
}
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
element); final Long writtenOff = this.fromApiJsonHelper
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull() .extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
.integerGreaterThanZero(); baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
.notNull().integerGreaterThanZero();
}
} }
} }
@@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData); accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) { } else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData); accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
// Expense // Expense
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) { } else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData); accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
@@ -470,6 +472,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData); accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) { } else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData); accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
// Expense // Expense
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) { } else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData); accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
@@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
PortfolioProductType.SAVING); PortfolioProductType.SAVING);
} }
/**
* Create-or-merge, for an expense row a product may not have yet.
*
* The plain merge variant assumes the mapping already exists, which is true of every expense
* row that shipped with the product screen. A row added later — the rounding adjustment — is
* absent on every product created before it, so editing one of those has to create the
* mapping rather than fail looking for it.
*/
public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName,
final Long productId, final int accountTypeId, final Map<String, Object> changes) {
createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE,
PortfolioProductType.SAVING);
}
/*** Abstractions for payments channel related to savings products ***/ /*** Abstractions for payments channel related to savings products ***/
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId, public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
@@ -248,6 +262,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes); CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
break; break;
case ACCRUAL_PERIODIC: case ACCRUAL_PERIODIC:
// asset // asset
@@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
break; break;
case ACCRUAL_UPFRONT: case ACCRUAL_UPFRONT:
@@ -269,7 +269,15 @@ public final class AccountingConstants {
OVERDRAFT_PORTFOLIO_CONTROL(11), // OVERDRAFT_PORTFOLIO_CONTROL(11), //
INCOME_FROM_INTEREST(12), // INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), // LOSSES_WRITTEN_OFF(13), //
ESCHEAT_LIABILITY(14); // ESCHEAT_LIABILITY(14), //
/**
* Where the paise dropped from a cash closure are absorbed.
*
* Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as
* 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered
* well clear of the accrual slots so the two sets never collide as either grows.
*/
ROUNDING_ADJUSTMENT(26); //
private final Integer value; private final Integer value;
@@ -317,7 +325,9 @@ public final class AccountingConstants {
FEES_RECEIVABLE(15), // FEES_RECEIVABLE(15), //
PENALTIES_RECEIVABLE(16), // PENALTIES_RECEIVABLE(16), //
INTEREST_PAYABLE(17), // INTEREST_PAYABLE(17), //
INTEREST_RECEIVABLE(18); INTEREST_RECEIVABLE(18), //
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
ROUNDING_ADJUSTMENT(26);
private final Integer value; private final Integer value;
@@ -372,7 +382,8 @@ public final class AccountingConstants {
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), // PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
INTEREST_RECEIVABLE("interestReceivableAccountId"), // INTEREST_RECEIVABLE("interestReceivableAccountId"), //
FEES_RECEIVABLE("feesReceivableAccountId"), // FEES_RECEIVABLE("feesReceivableAccountId"), //
INTEREST_PAYABLE("interestPayableAccountId"); INTEREST_PAYABLE("interestPayableAccountId"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
private final String value; private final String value;
@@ -410,7 +421,8 @@ public final class AccountingConstants {
FEES_RECEIVABLE("feeReceivableAccount"), // FEES_RECEIVABLE("feeReceivableAccount"), //
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), // PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
INTEREST_PAYABLE("interestPayableAccount"), // INTEREST_PAYABLE("interestPayableAccount"), //
INTEREST_RECEIVABLE("interestReceivableAccount"); // INTEREST_RECEIVABLE("interestReceivableAccount"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
private final String value; private final String value;
@@ -439,7 +451,32 @@ public final class AccountingConstants {
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), // PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), // DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), // RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); // DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
/**
* The year's closed-out profit a dividend is declared against.
*
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
* has already been through a distribution decision must not sit here, or the same money would be offered for
* distribution a second time.
*/
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
/**
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
* calculation.
*/
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
/**
* Where the paise a dividend cannot divide evenly are collected.
*
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
* a figure the society reads as a real business result. Given its own account it stays separately readable -
* and an auditor asking why members received less than was declared can be answered from the ledger.
*
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
*/
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
private final Integer value; private final Integer value;
private final String code; private final String code;
@@ -664,6 +664,14 @@ public class CommandWrapperBuilder {
return this; return this;
} }
public CommandWrapperBuilder deleteOffice(final Long officeId) {
this.actionName = "DELETE";
this.entityName = "OFFICE";
this.entityId = officeId;
this.href = "/offices/" + officeId;
return this;
}
public CommandWrapperBuilder createOfficeTransaction() { public CommandWrapperBuilder createOfficeTransaction() {
this.actionName = "CREATE"; this.actionName = "CREATE";
this.entityName = "OFFICETRANSACTION"; this.entityName = "OFFICETRANSACTION";
@@ -41,6 +41,8 @@ public enum JobName {
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), // FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), // "Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), // EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -54,6 +56,7 @@ public enum JobName {
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), // EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), // INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), // INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
LOAN_COB("Loan COB"), // LOAN_COB("Loan COB"), //
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), // LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), // SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
public void delete(final Office entity) { public void delete(final Office entity) {
this.repository.delete(entity); this.repository.delete(entity);
} }
/**
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
* than at transaction-commit time where it can no longer be turned into a clean error message.
*/
public void deleteAndFlush(final Office entity) {
this.repository.delete(entity);
this.repository.flush();
}
} }
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
private boolean active; private boolean active;
/**
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
* A rule named for withdrawals must not fire on a deposit.
*/
@Column(name = "direction", length = 10)
private String direction;
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
@Column(name = "cash_only", nullable = false)
private boolean cashOnly;
/**
* How many transactions must make up the window before a pattern rule fires. Structuring is
* many small transactions; without this floor a single large one satisfies the window total and
* reads as a pattern it is not.
*/
@Column(name = "min_transaction_count")
private Integer minTransactionCount;
/**
* Which customers a rule is about, where the rule is about the customer rather than the
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
* about the transaction alone and so applies to everyone.
*/
@Column(name = "subject_scope", length = 30)
private String subjectScope;
public String getSubjectScope() {
return subjectScope;
}
public String getDirection() {
return direction;
}
public boolean isCashOnly() {
return cashOnly;
}
public Integer getMinTransactionCount() {
return minTransactionCount;
}
protected AmlTransactionRule() {} protected AmlTransactionRule() {}
/** /**
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.dinVerified = false; this.dinVerified = false;
this.isException = false; this.isException = false;
this.exceptionResolved = false; this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant(); this.initiatedOn = DateUtils.getBusinessLocalDate();
} }
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) { public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
@@ -484,6 +484,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(riskParam, newValue); actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory); recordOverwrite(riskParam, this.riskCategory);
this.riskCategory = newValue; this.riskCategory = newValue;
// The re-KYC cycle is a property of the risk category, so moving the category moves the
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
// renewal and is never called back in for the two-yearly review the rating requires.
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
if (newExpiry != null) {
actualChanges.put("expiryDate", newExpiry);
}
} }
// remarks is the audit justification itself, so a change to it is deliberately not recorded // remarks is the audit justification itself, so a change to it is deliberately not recorded
@@ -554,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.form15Applicable = v); v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v); updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v); updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false); // The four exception columns are derived from whether the identity checks have passed, not
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v); // data an officer types, so they are updated without recording an overwrite. A verified
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false); // record demands remarks for any field it already held (see
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v); // getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
// counting these would make closing a settled exception impossible without a justification
// for something nobody chose. That is what left clients verified on the KYC tab while the
// exceptions register still listed them as Pending.
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
v -> this.isException = v != null ? v : false);
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
v -> this.exceptionReason = v);
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
v -> this.exceptionResolved = v != null ? v : false);
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
v -> this.exceptionResolvedOn = v);
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false); updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false); updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false); updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
@@ -612,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
* populated is the record being completed rather than altered, so it is deliberately not * populated is the record being completed rather than altered, so it is deliberately not
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}. * recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
*/ */
/*
* Derived-field variants of the update helpers above.
*
* Identical, minus the recordOverwrite call: these columns are computed from whether the
* identity checks have passed rather than entered by an officer, so replacing one is not the
* kind of change a verified record demands a justification for. They still report into
* `changes`, so the update is persisted and audited like any other.
*/
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
if (command.parameterExists(param)) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
changes.put(param, newValue);
setter.accept(newValue);
}
}
}
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(param, current)) {
final String newValue = command.stringValueOfParameterNamed(param);
changes.put(param, newValue);
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
}
}
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
if (command.isChangeInLocalDateParameterNamed(param, current)) {
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
changes.put(param, newValue);
setter.accept(newValue);
}
}
private void recordOverwrite(final String param, final Object currentValue) { private void recordOverwrite(final String param, final Object currentValue) {
final boolean wasPopulated = currentValue instanceof String text final boolean wasPopulated = currentValue instanceof String text
? StringUtils.isNotBlank(text) ? StringUtils.isNotBlank(text)
@@ -662,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markCkycUploaded(final String ckycNumber, final String batchId) { public void markCkycUploaded(final String ckycNumber, final String batchId) {
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null); this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant(); this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
} }
/** Called after successful 3rd-party verification of all required documents. */ /** Called after successful 3rd-party verification of all required documents. */
public void markVerified(final String ckycNumber) { public void markVerified(final String ckycNumber) {
this.kycStatus = ClientKycStatus.VERIFIED.getValue(); this.kycStatus = ClientKycStatus.VERIFIED.getValue();
this.verifiedOn = DateUtils.getLocalDateOfTenant(); this.verifiedOn = DateUtils.getBusinessLocalDate();
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory); final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
@@ -677,17 +734,45 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} }
} }
/**
* Re-dates the re-KYC due date after the risk category moves.
*
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
* High 2), and it is counted from the day of the change rather than from the original
* verification. A customer moved to High in year two of a Low rating is then due in two years'
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
* an overdue queue the moment a re-rating ran.
*
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
* {@link #markVerified} sets the date when it is first verified.
*
* @return the new expiry date, or {@code null} if the record carries no expiry
*/
private LocalDate recalculateExpiryForRiskCategory() {
if (this.verifiedOn == null) {
return null;
}
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
if (risk.getReKycYears() <= 0) {
return this.expiryDate;
}
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
return this.expiryDate;
}
/** Called when verification fails. */ /** Called when verification fails. */
public void markRejected(final String remarks) { public void markRejected(final String remarks) {
this.kycStatus = ClientKycStatus.REJECTED.getValue(); this.kycStatus = ClientKycStatus.REJECTED.getValue();
this.rejectedOn = DateUtils.getLocalDateOfTenant(); this.rejectedOn = DateUtils.getBusinessLocalDate();
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks); this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
} }
public void markPanVerified(final String fullName, final String firstName, final String lastName, public void markPanVerified(final String fullName, final String firstName, final String lastName,
final String aadhaarSeedingStatus, final String category, final String status) { final String aadhaarSeedingStatus, final String category, final String status) {
this.panVerified = true; this.panVerified = true;
this.panVerifiedOn = DateUtils.getLocalDateOfTenant(); this.panVerifiedOn = DateUtils.getBusinessLocalDate();
this.panFullName = StringUtils.defaultIfEmpty(fullName, null); this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null); this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
this.panLastName = StringUtils.defaultIfEmpty(lastName, null); this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
@@ -713,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markAadhaarVerified() { public void markAadhaarVerified() {
this.aadhaarVerified = true; this.aadhaarVerified = true;
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant(); this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
} }
public void markGstinVerified() { public void markGstinVerified() {
this.gstinVerified = true; this.gstinVerified = true;
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant(); this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
} }
public void markCinVerified() { public void markCinVerified() {
this.cinVerified = true; this.cinVerified = true;
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant(); this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
} }
public void markDinVerified() { public void markDinVerified() {
this.dinVerified = true; this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant(); this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
} }
public void markDinVerified(final String dinNumber, final String mcaResponseJson) { public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
this.dinNumber = dinNumber; this.dinNumber = dinNumber;
this.dinVerified = true; this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant(); this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
this.isDirector = true; this.isDirector = true;
this.dinMcaResponse = mcaResponseJson; this.dinMcaResponse = mcaResponseJson;
} }
@@ -755,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else if (type != null && type.isNonIndividual()) { } else if (type != null && type.isNonIndividual()) {
allDone = this.panVerified && this.gstinVerified; allDone = this.panVerified && this.gstinVerified;
} }
if (allDone && !getKycStatus().isVerified()) { // Re-stamped even when the record already reads VERIFIED, because that is exactly what a
// periodic re-KYC is: an already-verified customer verified afresh.
//
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
// that now measures from it — frozen at the original onboarding. An officer who re-verified
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
// account still sitting on the inactive report, with nothing to say what was missing.
//
// Guarded on the verifications rather than the status, so this only fires when the checks
// that constitute a verification have actually been done again.
if (allDone) {
markVerified(this.ckycNumber); markVerified(this.ckycNumber);
} }
return allDone; return allDone;
@@ -798,6 +893,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
*/ */
public void escalateRiskCategory(Integer newCategory) { public void escalateRiskCategory(Integer newCategory) {
this.riskCategory = newCategory; this.riskCategory = newCategory;
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
recalculateExpiryForRiskCategory();
} }
public String getPanNumber() { public String getPanNumber() {
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.client.domain; package org.apache.fineract.portfolio.client.domain;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
@@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status") @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status); List<ClientKyc> findAllByStatus(@Param("status") Integer status);
/**
* KYC records whose periodic re-KYC due date has passed, oldest first.
*
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
* never took effect, and restricting an account over it would punish the wrong thing.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
// ── Pre-KYC uniqueness probes ─────────────────────────────────────────── // ── Pre-KYC uniqueness probes ───────────────────────────────────────────
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the // Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN // external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
@@ -93,6 +93,26 @@ public class ClientRiskRule {
@Column(name = "order_position", nullable = false) @Column(name = "order_position", nullable = false)
private Integer orderPosition; private Integer orderPosition;
/**
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
*
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
* (Section 13).
*
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
* rule states what it is; nothing has to guess from the shape of its other columns.
*/
@Column(name = "rule_kind", length = 10)
private String ruleKind;
public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML";
protected ClientRiskRule() {} protected ClientRiskRule() {}
/** /**
@@ -104,8 +124,9 @@ public class ClientRiskRule {
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome, final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus, final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag, final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
final String dormantFlag) { final String dormantFlag, final String ruleKind) {
final ClientRiskRule rule = new ClientRiskRule(); final ClientRiskRule rule = new ClientRiskRule();
rule.ruleKind = ruleKind;
rule.ruleName = ruleName; rule.ruleName = ruleName;
rule.reason = reason; rule.reason = reason;
rule.riskCategory = riskCategory; rule.riskCategory = riskCategory;
@@ -137,6 +158,19 @@ public class ClientRiskRule {
return id; return id;
} }
public String getRuleKind() {
return ruleKind;
}
/**
* True when this rule may take part in onboarding risk categorisation. Rows predating the
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
* into the same table.
*/
public boolean isKycRule() {
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
}
public String getRuleName() { public String getRuleName() {
return ruleName; return ruleName;
} }
@@ -0,0 +1,106 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/**
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
*
* <p>
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
* a manual decision with its own notice.
*
* <p>
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
* notice" without fixing a number — so they are declared here once and read from here everywhere.
*/
public enum KycExpiryStage {
/**
* Inside the grace window. Nothing is restricted; the customer is being chased.
*/
GRACE(0),
/**
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
* the customer into the branch without putting their incoming money at risk.
*/
PARTIAL_FREEZE(90),
/**
* Both directions restricted, barring the credits that must always be let through.
*/
INOPERATIVE(180);
/** Days past the expiry date at which this stage begins. */
private final int fromDaysOverdue;
KycExpiryStage(final int fromDaysOverdue) {
this.fromDaysOverdue = fromDaysOverdue;
}
public int getFromDaysOverdue() {
return fromDaysOverdue;
}
/**
* The stage an account sits at, given how far past its re-KYC due date it is.
*
* @param daysOverdue
* days since the expiry date; zero or negative means the KYC has not lapsed
* @return the stage, or {@code null} where the KYC is still current and nothing applies
*/
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
if (daysOverdue < 0) {
return null;
}
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
return INOPERATIVE;
}
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
return PARTIAL_FREEZE;
}
return GRACE;
}
public boolean isGrace() {
return this == GRACE;
}
public boolean isPartialFreeze() {
return this == PARTIAL_FREEZE;
}
public boolean isInoperative() {
return this == INOPERATIVE;
}
/** Whether debits should be stopped at this stage. */
public boolean blocksDebits() {
return this == PARTIAL_FREEZE || this == INOPERATIVE;
}
/** Whether ordinary credits should be stopped at this stage. */
public boolean blocksCredits() {
return this == INOPERATIVE;
}
}
@@ -258,7 +258,6 @@ public class SavingsApiConstants {
public static final String neftFacilityParamName = "neftFacility"; public static final String neftFacilityParamName = "neftFacility";
public static final String rtgsFacilityParamName = "rtgsFacility"; public static final String rtgsFacilityParamName = "rtgsFacility";
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility"; public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
public static final String withdrawalFacilityParamName = "withdrawalFacility";
public static final String depositFacilityKycParamName = "depositFacilityKyc"; public static final String depositFacilityKycParamName = "depositFacilityKyc";
public static final String virtualDebitCardParamName = "virtualDebitCard"; public static final String virtualDebitCardParamName = "virtualDebitCard";
@@ -256,7 +256,16 @@ public final class PostingPeriod {
this.daysInYear = daysInYear; this.daysInYear = daysInYear;
this.compoundingPeriods = compoundingPeriods; this.compoundingPeriods = compoundingPeriods;
if (isSavingsInterestPostingAtCurrentPeriodEnd) { if (isUserPosting) {
// "Post interest as on D" — a closure, or the same action from the account screen. The
// period it covers ends at D-1, because interest is earned up to but not including the
// day it is paid, and SavingsHelper closes the period there. The transaction itself
// still belongs on D: that is the day the member was credited, the day the branch did
// the work, and the day the cash is handed over. Dating it to the period end instead
// stamps it a day before the account was even closed, and the ledger then disagrees with
// the closure request, the voucher and the passbook — all of which carry D.
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
this.dateOfPostingTransaction = periodInterval.endDate(); this.dateOfPostingTransaction = periodInterval.endDate();
} else { } else {
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1); this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
@@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility", nullable = false) @Column(name = "inter_bank_total_cap_facility", nullable = false)
private boolean interBankTotalCapFacility = false; private boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility", nullable = false)
private boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc", nullable = false) @Column(name = "deposit_facility_kyc", nullable = false)
private boolean depositFacilityKyc = false; private boolean depositFacilityKyc = false;
@@ -120,7 +117,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean()); if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean()); if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean()); if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean()); if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean()); if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean()); if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
@@ -136,9 +136,6 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "inter_bank_total_cap_facility") @Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility; private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc") @Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc; private Boolean depositFacilityKyc;
@@ -378,7 +378,6 @@ public class CurrentProductAssembler {
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false); config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false); config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false); config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false); config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false); config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
return config; return config;
@@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility") @Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility = false; private Boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc") @Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc = false; private Boolean depositFacilityKyc = false;
@@ -125,8 +122,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; } public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; } public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; } public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; } public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; } public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
public Boolean getVirtualDebitCard() { return virtualDebitCard; } public Boolean getVirtualDebitCard() { return virtualDebitCard; }
@@ -147,7 +142,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
this.neftFacility = other.getNeftFacility(); this.neftFacility = other.getNeftFacility();
this.rtgsFacility = other.getRtgsFacility(); this.rtgsFacility = other.getRtgsFacility();
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility(); this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
this.withdrawalFacility = other.getWithdrawalFacility();
this.depositFacilityKyc = other.getDepositFacilityKyc(); this.depositFacilityKyc = other.getDepositFacilityKyc();
this.virtualDebitCard = other.getVirtualDebitCard(); this.virtualDebitCard = other.getVirtualDebitCard();
} }
@@ -117,7 +117,6 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility()); facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility()); facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility()); facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc()); facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard()); facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
product.setAccountFacilityConfig(facilityConfigMap); product.setAccountFacilityConfig(facilityConfigMap);
@@ -0,0 +1,243 @@
/**
* 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.balancesheet.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
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.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* An "as on date" balance sheet built directly off the live chart of accounts and journal entries,
* rather than a stock Fineract trial balance report — this reproduces the specific sub-group-wise
* layout this bank's own paper balance sheet uses: every top-level Header account (Capital, Deposit
* Account, Other Liabilities, ...) is one named block, with its Detail (and any nested Header)
* children indented underneath it and a subtotal on the block itself. A Detail account with no
* Header at all (see GLAccountTransactionGuard's sibling concern - some accounts are seeded standalone)
* still needs to appear somewhere, so it is listed as its own root-level line rather than silently
* dropped.
*
* A Header account never itself carries a journal entry (see the header/detail discussion this
* feature grew out of), so its balance is always the recursive sum of its children - never read off
* acc_gl_journal_entry directly.
*/
@Path("/v1/accounting/balancesheet")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BalanceSheetApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "GLACCOUNT";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
// classification_enum values (see acc_gl_account / GLAccountType).
private static final int ASSET = 1;
private static final int LIABILITY = 2;
private static final int EQUITY = 3;
private static final int INCOME = 4;
private static final int EXPENSE = 5;
// account_usage values (see GLAccountUsage).
private static final int DETAIL = 1;
private static final int HEADER = 2;
private static final class Node {
Long id;
String name;
String glCode;
int usage;
int classification;
Long parentId;
BigDecimal ownBalance = BigDecimal.ZERO;
final List<Node> children = new ArrayList<>();
BigDecimal balance() {
if (usage == DETAIL) {
return ownBalance;
}
BigDecimal sum = BigDecimal.ZERO;
for (final Node child : children) {
sum = sum.add(child.balance());
}
return sum;
}
Map<String, Object> toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("id", id);
json.put("name", name);
json.put("glCode", glCode);
json.put("usage", usage == HEADER ? "HEADER" : "DETAIL");
json.put("balance", balance());
final List<Map<String, Object>> childJson = new ArrayList<>();
for (final Node child : children) {
childJson.add(child.toJson());
}
json.put("children", childJson);
return json;
}
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
public String retrieveBalanceSheet(@QueryParam("asOnDate") final String asOnDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate asOnDate = (asOnDateParam == null || asOnDateParam.isBlank()) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(asOnDateParam);
final Map<Long, Node> nodesById = new LinkedHashMap<>();
for (final Map<String, Object> row : this.jdbcTemplate.queryForList(
"SELECT id, name, gl_code, parent_id, account_usage, classification_enum FROM acc_gl_account "
+ "WHERE disabled = false ORDER BY gl_code")) {
final Node node = new Node();
node.id = ((Number) row.get("id")).longValue();
node.name = (String) row.get("name");
node.glCode = (String) row.get("gl_code");
node.parentId = row.get("parent_id") == null ? null : ((Number) row.get("parent_id")).longValue();
node.usage = ((Number) row.get("account_usage")).intValue();
node.classification = ((Number) row.get("classification_enum")).intValue();
nodesById.put(node.id, node);
}
// entry_date (not transaction_date) and "reversed = false" are this codebase's settled
// convention for reading balances off the ledger - ProfitAndLossApiResource, the dividend
// reports and IncomeProfitTransferService all cut the same way, so this report agrees with
// them rather than quietly producing a second set of numbers.
//
// The reversal_id exclusion covers a case that plain "reversed = false" gets wrong. This
// codebase reverses in two different ways: the clerk/sponsor-DD undo paths just flip the
// flag on the original leg (no counter entry - excluding it is exactly right), while
// Fineract's own revertJournalEntry additionally posts an opposite-sign counter entry and
// links it back through reversal_id. For that second kind, dropping only the original would
// leave its counter entry standing alone and swing the balance by the full amount in the
// wrong direction, so the counter entry has to go too.
final Map<Long, BigDecimal[]> balances = new LinkedHashMap<>();
final String balanceSql = "SELECT account_id, "
+ "COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, "
+ "COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit "
+ "FROM acc_gl_journal_entry WHERE entry_date <= ? "
+ "AND (reversed IS NULL OR reversed = false) "
+ "AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)"
+ (officeId != null ? " AND office_id = ?" : "") + " GROUP BY account_id";
final List<Map<String, Object>> balanceRows = officeId != null
? this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate), officeId)
: this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate));
for (final Map<String, Object> row : balanceRows) {
balances.put(((Number) row.get("account_id")).longValue(),
new BigDecimal[] { (BigDecimal) row.get("total_debit"), (BigDecimal) row.get("total_credit") });
}
for (final Node node : nodesById.values()) {
if (node.usage != DETAIL) {
continue;
}
final BigDecimal[] debitCredit = balances.get(node.id);
if (debitCredit == null) {
continue;
}
final boolean debitNatured = node.classification == ASSET || node.classification == EXPENSE;
node.ownBalance = debitNatured ? debitCredit[0].subtract(debitCredit[1]) : debitCredit[1].subtract(debitCredit[0]);
}
final List<Node> roots = new ArrayList<>();
for (final Node node : nodesById.values()) {
if (node.parentId == null) {
roots.add(node);
} else {
final Node parent = nodesById.get(node.parentId);
if (parent != null) {
parent.children.add(node);
} else {
// Parent was disabled (filtered out above) - surface this node at the top level
// rather than silently losing its balance from the report.
roots.add(node);
}
}
}
final Map<String, Object> debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE);
final Map<String, Object> creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOnDate.toString());
response.put("officeId", officeId);
response.put("debitSide", debitSide);
response.put("creditSide", creditSide);
return this.gson.toJson(response);
}
private Map<String, Object> buildSide(final String label, final List<Node> roots, final int... classifications) {
final List<Map<String, Object>> groups = new ArrayList<>();
BigDecimal sideTotal = BigDecimal.ZERO;
for (final int classification : classifications) {
final List<Map<String, Object>> nodeJsons = new ArrayList<>();
BigDecimal groupTotal = BigDecimal.ZERO;
for (final Node root : roots) {
if (root.classification != classification) {
continue;
}
nodeJsons.add(root.toJson());
groupTotal = groupTotal.add(root.balance());
}
if (!nodeJsons.isEmpty()) {
final Map<String, Object> group = new LinkedHashMap<>();
group.put("classification", classificationLabel(classification));
group.put("total", groupTotal);
group.put("nodes", nodeJsons);
groups.add(group);
sideTotal = sideTotal.add(groupTotal);
}
}
final Map<String, Object> side = new LinkedHashMap<>();
side.put("label", label);
side.put("total", sideTotal);
side.put("groups", groups);
return side;
}
private String classificationLabel(final int classification) {
return switch (classification) {
case ASSET -> "ASSET";
case LIABILITY -> "LIABILITY";
case EQUITY -> "EQUITY";
case INCOME -> "INCOME";
case EXPENSE -> "EXPENSE";
default -> "OTHER";
};
}
}
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.common;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator
* configures.
*
* <p>
* These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread
* across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend
* journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from
* the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one
* screen.
*
* <p>
* Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made
* the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws
* rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to
* configure, instead of leaving a declaration that appears to have worked but moved nothing.
*/
@Service
@RequiredArgsConstructor
public class DividendGlAccountResolver {
private final JdbcTemplate jdbcTemplate;
/** The year's closed-out profit a dividend is declared against. */
public Long distributableProfitAccount() {
return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT);
}
/** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */
public Long retainedProfitAccount() {
return requireMapped(FinancialActivity.RETAINED_PROFIT);
}
/** Equity the declared profit is set aside in, between declaration and board approval. */
public Long reserveForDividendAccount() {
return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND);
}
/** The liability to members, created on approval and discharged as each member claims. */
public Long payableDividendsAccount() {
return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS);
}
/**
* Where the paise a dividend cannot divide evenly are swept.
*
* Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed
* - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets
* the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than
* a decision the society has failed to make, which is why it resolves rather than throwing.
*/
public Long dividendRoundOffAccount() {
final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF);
return roundOff != null ? roundOff : retainedProfitAccount();
}
/**
* Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure.
*
* Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend
* Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it
* returns null instead of throwing.
*/
public Long dividendPaidAccountOrNull() {
return mappedAccount(FinancialActivity.DIVIDEND_PAID);
}
/** The GL mapped to an activity, or null when the society has not configured it. */
public Long mappedAccount(final FinancialActivity activity) {
return this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
activity.getValue())
.stream().findFirst().orElse(null);
}
private Long requireMapped(final FinancialActivity activity) {
final Long glAccountId = mappedAccount(activity);
if (glAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured",
"No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity "
+ "Mappings before running the dividend cycle.",
activity.getValue());
}
return glAccountId;
}
}
@@ -0,0 +1,58 @@
/**
* 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.glaccount.validation;
import org.apache.fineract.accounting.glaccount.domain.GLAccountUsage;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* A Header GL account (account_usage = HEADER) is a pure grouping node in the chart-of-accounts tree - it
* exists to organize Detail accounts underneath it, never to receive a transaction itself. The standard
* Fineract journal-entry command path (JournalEntryWritePlatformServiceJpaRepositoryImpl) never checked
* this - only "disabled" and "manual entries allowed" - and several features in this codebase post journal
* entries via direct JDBC INSERT into acc_gl_journal_entry, bypassing that command path entirely. This is
* the one shared check every such call site uses so a Header account can never end up with its own
* transaction (which would silently break every report that assumes a Header's balance is just the sum of
* its Detail children).
*/
public final class GLAccountTransactionGuard {
private GLAccountTransactionGuard() {}
public static void assertPostable(final JdbcTemplate jdbcTemplate, final Long glAccountId) {
if (glAccountId == null) {
return;
}
final Integer usage;
try {
usage = jdbcTemplate.queryForObject("SELECT account_usage FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.glaccount.not.found",
"GL account with id " + glAccountId + " does not exist.", "glAccountId", glAccountId);
}
if (GLAccountUsage.HEADER.getValue().equals(usage)) {
throw new PlatformDataIntegrityException("error.msg.glaccount.header.account.not.postable",
"GL account " + glAccountId + " is a Header account (a chart-of-accounts grouping node) and cannot receive a "
+ "transaction directly. Select a Detail account instead.",
"glAccountId", glAccountId);
}
}
}
@@ -25,12 +25,14 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository; import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
import org.apache.fineract.accounting.common.AccountingConstants; import org.apache.fineract.accounting.common.AccountingConstants;
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
@@ -77,6 +79,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AccountingProcessorHelper { public class AccountingProcessorHelper {
@@ -89,6 +92,14 @@ public class AccountingProcessorHelper {
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
/**
* Reserved names on the savings product's Accounting tab, naming the two accounts an interest
* posting is routed through. The names are the contract with the product screen; the GL codes
* behind them are the bank's choice, so no code is named here.
*/
public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable";
public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid";
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
public static void setDepositAccountTypeContext(int depositAccountType) { public static void setDepositAccountTypeContext(int depositAccountType) {
@@ -116,6 +127,10 @@ public class AccountingProcessorHelper {
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
private final ChargeRepositoryWrapper chargeRepositoryWrapper; private final ChargeRepositoryWrapper chargeRepositoryWrapper;
private final BusinessEventNotifierService businessEventNotifierService; private final BusinessEventNotifierService businessEventNotifierService;
// The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a
// JSON document rather than in acc_product_mapping, so they are read directly. See
// namedSavingsGlAccount.
private final JdbcTemplate jdbcTemplate;
public LoanDTO populateLoanDtoFromDTO( public LoanDTO populateLoanDtoFromDTO(
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
@@ -600,6 +615,144 @@ public class AccountingProcessorHelper {
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
} }
/**
* Interest credited to a member, routed through the payable and paid accounts the product names.
*
* <pre>
* Dr Interest On Savings (expense) the bank incurs the cost
* Cr Savings Interest Payable (liability) and now owes it
*
* Dr Savings Interest Payable
* Cr Savings Interest Paid the debt is on its way out
*
* Dr Savings Interest Paid
* Cr Savings Control and reaches the member
* </pre>
*
* <p>
* The two middle accounts net to nil across the three steps, so the trial balance is unchanged
* from the plain two-legged entry; what they add is a reading of how much interest has been
* declared but not yet credited, which a single Dr/Cr pair cannot show.
*
* <p>
* Both are read from the product's Accounting tab and neither is required. A product that names
* no paid account collapses the middle step, and one that names no payable falls back to its own
* Savings Control - either way the posting degrades to exactly the two-legged entry it was
* before, so products configured before these mappings existed are unaffected.
*
* <p>
* Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings}
* does for the two-legged case.
*/
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
// where a bank actually picks it. The reserved name in the product's other GL mappings is
// consulted only after that, for a product configured before that row was mapped, and the
// Savings Control last of all - interest owed is a liability either way.
final GLAccount payableAccount = Optional
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
.orElse(controlAccount);
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
.orElse(payableAccount);
// The interest expense is not raised here. It is recognised as the interest accrues, day by
// day, and this posting settles that recognised debt - raising the expense again at payout
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
// which pays a product's whole membership the same way; a closure reaching this path must
// produce the same entries as the nightly run, or the two disagree on what one payment did.
//
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
// product names no paid account both resolve to the payable and this says nothing, so it is
// skipped and the payout comes straight out of the payable.
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
if (!payableIsPaid) {
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
// Stage 2: into the member's own balance.
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
/**
* One Dr/Cr pair, with both sides swapped when the transaction is a reversal.
*
* <p>
* A leg whose two sides are the same account writes nothing. That is not a defensive check but
* the mechanism by which the three stages above collapse: a product that names no payable and no
* paid account resolves all three to its Savings Control, and what should remain is the plain
* {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping
* the middle stage by hand was not enough — stage 3 still resolved to Control against Control and
* wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's
* debits and credits unequal per account and shows up on the trial balance as movement that never
* happened. Deciding it here rather than at each call site means every collapse works the same
* way, whichever of the two names a product happens to leave unset.
*/
private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount,
final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate,
final BigDecimal amount, final boolean isReversal) {
if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) {
return;
}
final GLAccount toDebit = isReversal ? creditAccount : debitAccount;
final GLAccount toCredit = isReversal ? debitAccount : creditAccount;
createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount);
createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount);
}
/**
* A GL account the savings product maps under one of the reserved names on its Accounting tab.
*
* <p>
* These live in {@code m_savings_product.other_gl_mappings} as a JSON document of
* {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in
* {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's
* fixed financial account types. The name is matched across all four groups: which group a bank
* files it under is a presentation choice, and an account configured under the wrong heading
* should still be honoured rather than silently ignored.
*
* @return the account, or null where the product does not configure this name
*/
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
if (savingsProductId == null) {
return null;
}
// The column holds an empty string on a product saved with no named mappings, and casting
// that to jsonb is an error rather than an empty document - so the cast is done inside a
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
// clause in any order, so guarding at the same query level as the cast would only work by
// luck.
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE m.entry ->> 'accountName' = ?
LIMIT 1
""", Long.class, savingsProductId, accountName);
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
}
/** /**
* Creates a direct transfer journal entry between two savings-type accounts (savings or current), * Creates a direct transfer journal entry between two savings-type accounts (savings or current),
* each with their own product GL mapping and entity ID so the correct account number appears per entry. * each with their own product GL mapping and entity ID so the correct account number appears per entry.
@@ -1241,6 +1394,29 @@ public class AccountingProcessorHelper {
return accountMapping.getGlAccount(); return accountMapping.getGlAccount();
} }
/**
* One row of the savings product's Accounting tab, or null where the bank has not filled it in.
*
* <p>
* Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is
* required. Interest Payable is offered on the product screen but a product configured before it
* existed has none, and that has to fall through to the next choice rather than stopping the
* account from being closed.
*/
private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) {
if (savingsProductId == null) {
return null;
}
// Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a
// missing mapping that method builds its message through CashAccountsForSavings.fromInt,
// which returns null for a row the cash enum does not carry - Interest Payable among them -
// and the lookup would die on a NullPointerException instead of the exception meant for it.
final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId,
PortfolioProductType.SAVING.getValue(), accountMappingTypeId);
return mapping == null ? null : mapping.getGlAccount();
}
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) { final Long paymentTypeId) {
GLAccount glAccount; GLAccount glAccount;
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// Post journal entry if earned interest amount is greater than // Post journal entry if earned interest amount is greater than
// zero // zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, // Routed through the payable and paid accounts the product names, so the society
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), // can read interest declared but not yet credited as its own balance. Collapses
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); // to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, amount, isReversal);
} }
} }
@@ -21,6 +21,7 @@ package org.apache.fineract.accounting.journalentry.service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -391,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) { } else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null, throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode()); creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
} }
} }
@@ -775,15 +779,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue()); final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
validateBusinessRulesForJournalEntries(journalEntryCommand); validateBusinessRulesForJournalEntries(journalEntryCommand);
validateNoDenominationManagedCashAccounts(journalEntryCommand);
/** /**
* revert old journal entries * revert old journal entries
*
* A transaction that carries a leg against the denomination-managed cash account (see
* validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is
* tied to this same office+contra pairing (Migrate Cash Denomination posts its opening
* entry against this same Opening Balances Contra account), so the query above finds it
* too. Reversing it here, while the new submission is barred from ever re-posting a cash
* leg, would silently erase the cash account's opening balance every time an admin
* redefines opening balances for any OTHER account at this office.
*/ */
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId, final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
officeId); officeId);
for (String transactionId : transactionIdsToBeReversed) { for (String transactionId : transactionIdsToBeReversed) {
final List<JournalEntry> journalEntries = this.glJournalEntryRepository final List<JournalEntry> journalEntries = this.glJournalEntryRepository
.findUnReversedManualJournalEntriesByTransactionId(transactionId); .findUnReversedManualJournalEntriesByTransactionId(transactionId);
final boolean touchesDenominationManagedCashAccount = journalEntries.stream()
.anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId()));
if (touchesDenominationManagedCashAccount) {
continue;
}
revertJournalEntry(journalEntries, "defining opening balance"); revertJournalEntry(journalEntries, "defining opening balance");
} }
@@ -865,6 +884,55 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
} }
} }
/**
* The GL account(s) Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) map to. Shared
* by the validation below and by defineOpeningBalance's own revert step, so both agree on exactly
* which account(s) Migrate Cash Denomination owns.
*/
private List<Long> resolveDenominationManagedCashGlAccountIds() {
final List<Long> ids = new ArrayList<>();
for (final FinancialActivityAccount mapping : this.financialActivityAccountRepositoryWrapper.findAll()) {
final Integer type = mapping.getFinancialActivityType();
if ((type != null && (type == 101 || type == 102)) && mapping.getGlAccount() != null) {
ids.add(mapping.getGlAccount().getId());
}
}
return ids;
}
/**
* The GL account(s) mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) are
* managed exclusively through Migrate Cash Denomination (BranchDayRegisterApiResource#migrateCashDenomination),
* which keeps the GL figure tied to an actual counted note-and-coin breakdown, and validates any
* redefinition against that same count. Defining or redefining that account's opening balance here
* instead - as a bare number, with no denomination breakdown behind it - would let the GL figure
* drift away from what is physically in the till, with nothing left to catch the mismatch. So this
* command refuses to touch either of those accounts at all, on either side of the entry, rather than
* only when the amount would differ from what is already posted: the physical count is the only
* source of truth for this account, never a number typed into a generic opening-balance grid.
*/
private void validateNoDenominationManagedCashAccounts(final JournalEntryCommand command) {
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
if (denominationManagedGlAccountIds.isEmpty()) {
return;
}
final List<SingleDebitOrCreditEntryCommand> allEntries = new ArrayList<>();
if (command.getDebits() != null) {
allEntries.addAll(Arrays.asList(command.getDebits()));
}
if (command.getCredits() != null) {
allEntries.addAll(Arrays.asList(command.getCredits()));
}
for (final SingleDebitOrCreditEntryCommand entry : allEntries) {
if (denominationManagedGlAccountIds.contains(entry.getGlAccountId())) {
throw new GeneralPlatformDomainRuleException("error.msg.journalentry.opening.balance.cash.account.locked",
"This account is the tenant's Cash at Vault / Cash at Teller account and is managed through "
+ "Migrate Cash Denomination - it cannot be set or changed from Migrate Opening Balances.",
entry.getGlAccountId());
}
}
}
@Override @Override
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) { public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData); final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
@@ -68,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository, FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
GLAccountRepository glAccountRepository, OfficeRepository officeRepository, GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper, AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
BusinessEventNotifierService businessEventNotifierService) { BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository, return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper, closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
businessEventNotifierService); businessEventNotifierService, jdbcTemplate);
} }
@Bean @Bean
@@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue()); CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
} }
// Optional: only mapped when the product screen supplies it. Every savings product that
// existed before this row was added has none, and a closure on one of those still has to
// work — see the closure's own fallback.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element,
SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId,
CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue());
}
// advanced accounting mappings // advanced accounting mappings
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null); this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null); this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
@@ -37,6 +37,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -60,13 +61,12 @@ import org.springframework.stereotype.Component;
public class DistributableProfitApiResource { public class DistributableProfitApiResource {
/** /**
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with * The year's closed-out profit, which is what a dividend may be declared against.
* IncomeProfitTransferService.PROFIT_GL_CODE.
* *
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would * Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
* offer profit already distributed once for distribution again. * offer profit already distributed once for distribution again.
*/ */
private static final String PROFIT_GL_CODE = "BANKPROFITND01"; private final DividendGlAccountResolver dividendGlAccounts;
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage"; private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
@@ -162,9 +162,9 @@ public class DistributableProfitApiResource {
final BigDecimal balance = jdbcTemplate.queryForObject(""" final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? """, BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance; return balance == null ? BigDecimal.ZERO : balance;
} }
@@ -58,7 +58,7 @@ public class IncomeProfitTransferApiResource {
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: incomeGlCodeParam.trim(); : incomeGlCodeParam.trim();
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode); final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE); final Map<String, Object> profitGl = service.loadDistributableProfitGl();
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear() final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
: financialYearParam.trim(); : financialYearParam.trim();
@@ -10,6 +10,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.EmptyResultDataAccessException;
@@ -22,7 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
* *
* <pre> * <pre>
* Dr Share Entry Fee Income (10.4) = balance for the year * Dr Share Entry Fee Income (10.4) = balance for the year
* Cr Bank Profit (BANKPROFIT01) * Cr Distributable Profit (the GL mapped to that financial activity)
* </pre> * </pre>
* *
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as * Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
@@ -42,22 +44,23 @@ public class IncomeProfitTransferService {
private static final int DEBIT = 2; private static final int DEBIT = 2;
private static final int CREDIT = 1; 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. */ /** Income GL closed to profit by the year-end job — the share entry fee. */
public static final String DEFAULT_INCOME_GL_CODE = "10.4"; public static final String DEFAULT_INCOME_GL_CODE = "10.4";
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
/**
* The year's profit is closed into the Distributable Profit account, not the Retained one.
*
* A dividend is declared against this balance; whatever the members do not receive moves across to Retained Profit
* once the declaration is approved. Closing into Retained 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.
*
* Both accounts are configured on Financial Activity Mappings; this was the hardcoded GL code BANKPROFITND01.
*/
private final DividendGlAccountResolver dividendGlAccounts;
/** /**
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when * 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 * there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
@@ -72,7 +75,7 @@ public class IncomeProfitTransferService {
final String note, final boolean strict) { final String note, final boolean strict) {
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode); final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE); final Map<String, Object> profitGl = loadDistributableProfitGl();
final Long incomeGlId = toLong(incomeGl.get("id")); final Long incomeGlId = toLong(incomeGl.get("id"));
final Long profitGlId = toLong(profitGl.get("id")); final Long profitGlId = toLong(profitGl.get("id"));
@@ -165,7 +168,7 @@ public class IncomeProfitTransferService {
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note, public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
final boolean strict) { final boolean strict) {
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE); final Map<String, Object> profitGl = loadDistributableProfitGl();
final Long profitGlId = toLong(profitGl.get("id")); final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear); final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId(); final Long officeId = headOfficeId();
@@ -283,7 +286,7 @@ public class IncomeProfitTransferService {
*/ */
public BigDecimal profitForFinancialYear(final String financialYear) { public BigDecimal profitForFinancialYear(final String financialYear) {
final LocalDate[] range = financialYearRange(financialYear); final LocalDate[] range = financialYearRange(financialYear);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE); final Map<String, Object> profitGl = loadDistributableProfitGl();
final BigDecimal balance = jdbcTemplate.queryForObject( final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) " "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 ?", + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
@@ -342,6 +345,21 @@ public class IncomeProfitTransferService {
} }
} }
/** As {@link #loadGlByCode}, for an account already identified by a financial activity mapping. */
public Map<String, Object> loadGlById(final Long glAccountId) {
try {
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE id = ?", glAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found",
"GL account " + glAccountId + " does not exist.");
}
}
/** The distributable-profit GL the year's income closes into, as configured on Financial Activity Mappings. */
public Map<String, Object> loadDistributableProfitGl() {
return loadGlById(this.dividendGlAccounts.distributableProfitAccount());
}
public Long headOfficeId() { public Long headOfficeId() {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
} }
@@ -359,6 +377,7 @@ public class IncomeProfitTransferService {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, 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) { final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, " + " entry_date, type_enum, amount, description, "
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate;
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 SyncBusinessDateConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step syncBusinessDateStep(final SyncBusinessDateTasklet syncBusinessDateTasklet) {
return new StepBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository)
.tasklet(syncBusinessDateTasklet, transactionManager).build();
}
@Bean
public Job syncBusinessDateJob(final SyncBusinessDateTasklet syncBusinessDateTasklet) {
return new JobBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository)
.start(syncBusinessDateStep(syncBusinessDateTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,88 @@
/**
* 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.infrastructure.businessdate.jobs.syncbusinessdate;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.businessdate.data.service.BusinessDateDTO;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateWritePlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Sets the business date to the tenant's own calendar date, every midnight.
*
* <p>
* Fineract's stock job adds one day to whatever the business date already holds. That is only the
* same thing as "today" while the scheduler has never missed a night: a server stopped over a
* weekend, a laptop that was closed, a tenant restored from an older backup, and the date is left
* behind by however many runs did not happen. It then stays behind, because adding a day to a stale
* date gives another stale date, and each night's run preserves the gap rather than closing it.
*
* <p>
* Drift is not a cosmetic problem. Every transaction is stamped with the business date, so while it
* lags: a Demand Draft raised today is rejected as being dated in the future, the day's savings
* interest never accrues and a closure pays out short, and staff see yesterday's date on work they
* are doing now.
*
* <p>
* Reading the clock instead of incrementing is self-correcting: however many days were missed, the
* next run lands on today. Bringing the date forward is all this does — a date deliberately set
* ahead, which is how testing against a future day works, is left alone rather than pulled back
* under someone's feet.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class SyncBusinessDateTasklet implements Tasklet {
private final BusinessDateWritePlatformService businessDateWritePlatformService;
private final ConfigurationDomainService configurationDomainService;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
if (!configurationDomainService.isBusinessDateEnabled()) {
// Without the setting on, the platform reads the clock directly and m_business_date is
// not consulted — writing to it would be busywork that misleads anyone reading the table.
log.debug("Business date is not enabled; nothing to sync.");
return RepeatStatus.FINISHED;
}
final LocalDate systemDate = DateUtils.getLocalDateOfTenant();
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (!businessDate.isBefore(systemDate)) {
log.debug("Business date {} is already at or ahead of the system date {}; leaving it alone.", businessDate, systemDate);
return RepeatStatus.FINISHED;
}
businessDateWritePlatformService.updateBusinessDate(BusinessDateDTO.builder().type(BusinessDateType.BUSINESS_DATE)
.description(BusinessDateType.BUSINESS_DATE.getDescription()).date(systemDate).build());
log.info("Business date moved from {} to the system date {}.", businessDate, systemDate);
return RepeatStatus.FINISHED;
}
}
@@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDoma
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter; import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter; import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter;
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter; import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper; import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter; import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
@@ -163,6 +164,24 @@ public class SecurityConfig {
// packages here without a Fineract Bearer token; security is enforced by the // packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
// it just fails inside the handler instead of at this filter.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
// Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above
// for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary
// session when no key is sent, same as ClientAllAccountsApiResource does; permitAll
// here only opens Spring Security's outer gate for the key path, it does not skip
// the handler's own check). The PUT has no session fallback inside the handler at
// all, even for a logged-in super user — see TenantMenuAccessApiResource#save.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll()
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed, // Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
// no Fineract auth required. // no Fineract auth required.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
@@ -408,7 +427,10 @@ public class SecurityConfig {
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class) .addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class) .addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class) .addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class); .addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class)
// Refuses the handful of accounting features switched off for this installation.
// Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why.
.addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class);
if (loanCOBFilterHelper != null) { if (loanCOBFilterHelper != null) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(), http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
@@ -445,6 +467,10 @@ public class SecurityConfig {
return new RequestResponseFilter(); return new RequestResponseFilter();
} }
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
return new DisabledFeatureApiFilter();
}
public LoanCOBApiFilter loanCOBApiFilter() { public LoanCOBApiFilter loanCOBApiFilter() {
return new LoanCOBApiFilter(loanCOBFilterHelper); return new LoanCOBApiFilter(loanCOBFilterHelper);
} }
@@ -0,0 +1,128 @@
/**
* 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.infrastructure.core.filters;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Turns off a handful of accounting features at the HTTP edge, without deleting any of the code
* behind them: the resources, services and command handlers all stay exactly as they are, and only
* an inbound API call is refused. Re-enabling a feature is deleting its rule from RULES below.
*
* Blocking here rather than inside the resources matters for one specific reason: several internal
* flows raise journal entries through CommandWrapperBuilder().createJournalEntry() in Java
* (ClerkDisbursementRequestApiResource's GL offsets, clerk closure rounding, bank transfers). Those
* never travel over HTTP, so they keep working, while the "Create Journal Entries" screen and any
* direct call to the endpoint are refused.
*
* A rule carries an optional set of allowed "command" query parameter values, because
* POST /v1/journalentries is three different features behind one path: no command is the manual
* journal entry screen (blocked), command=defineOpeningBalance is Migrate Opening Balances and
* command=updateRunningBalance is the running-balance maintenance job (both still needed).
*/
public class DisabledFeatureApiFilter extends OncePerRequestFilter {
private record Rule(String feature, Set<String> methods, Pattern path, Set<String> allowedCommands) {
boolean matches(final String method, final String pathInfo, final String command) {
if (!methods.isEmpty() && !methods.contains(method)) {
return false;
}
if (!path.matcher(pathInfo).matches()) {
return false;
}
// A command this rule explicitly spares (a different feature sharing the same path).
return !(command != null && allowedCommands.contains(command));
}
}
private static final List<Rule> RULES = List.of(
// Create Journal Entries, and Frequent Postings which posts through the same endpoint.
new Rule("Create Journal Entries", Set.of("POST"), Pattern.compile("/v[1-9][0-9]*/journalentries/?"),
Set.of("defineOpeningBalance", "updateRunningBalance")),
// Closing Entries.
new Rule("Closing Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/glclosures(/.*)?"), Set.of()),
// Accruals.
new Rule("Accruals", Set.of(), Pattern.compile("/v[1-9][0-9]*/runaccruals(/.*)?"), Set.of()),
// Provisioning Entries, including the journal entries screen that belongs to it.
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/provisioningentries(/.*)?"), Set.of()),
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/journalentries/provisioning/?"), Set.of()),
// Accounting Rules. Only the Accounting Rules screens and Frequent Postings (disabled
// above) read these, so nothing still reachable depends on them.
new Rule("Accounting Rules", Set.of(), Pattern.compile("/v[1-9][0-9]*/accountingrules(/.*)?"), Set.of()));
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
throws ServletException, IOException {
final String pathInfo = request.getPathInfo();
if (StringUtils.isBlank(pathInfo)) {
filterChain.doFilter(request, response);
return;
}
final String method = request.getMethod();
// Read "command" out of the query string rather than through getParameter(): the latter
// triggers servlet parameter parsing, which for a form-encoded or multipart POST consumes
// the request body before the resource ever sees it. This filter sits in front of every
// API call, so it must not touch the body of requests it has no interest in.
final String command = commandFromQueryString(request.getQueryString());
for (final Rule rule : RULES) {
if (rule.matches(method, pathInfo, command)) {
reject(response, rule.feature());
return;
}
}
filterChain.doFilter(request, response);
}
private String commandFromQueryString(final String queryString) {
if (StringUtils.isBlank(queryString)) {
return null;
}
for (final String pair : queryString.split("&")) {
if (pair.startsWith("command=")) {
return URLDecoder.decode(pair.substring("command=".length()), StandardCharsets.UTF_8);
}
}
return null;
}
private void reject(final HttpServletResponse response, final String feature) throws IOException {
response.setStatus(HttpStatus.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter()
.write("{\"developerMessage\":\"" + feature + " is disabled on this installation.\","
+ "\"httpStatusCode\":\"403\",\"defaultUserMessage\":\"" + feature + " is not available.\","
+ "\"userMessageGlobalisationCode\":\"error.msg.feature.disabled\"}");
}
}
@@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
.retrieveAllJobs(fineractProperties.getNodeId()); .retrieveAllJobs(fineractProperties.getNodeId());
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) { for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
// Release any currently_running flag left over from before this boot.
//
// The flag is set in the scheduler's veto check before a job's body runs and cleared
// by the Quartz listener when it returns. Two things leave it set with nothing
// running: a JVM that stopped mid-run, and a job started through its own API rather
// than through Quartz — no listener is involved there, so nothing ever clears it.
//
// Once set, the veto silently drops every later trigger: no run-history row, no
// error, nothing in the log. The job reads as though it ran and did nothing, and one
// dead run mutes it for good. That is how a night of unpaid savings interest goes
// unnoticed.
//
// This loop is the platform coming up, so by definition none of these jobs is
// running: whatever the flag says, it is stale. scheduleJob() below already clears
// it on the paths it reaches, but not when it throws before that line, and the
// saveOrUpdate() after this loop persists whatever we set here either way.
jobDetails.setCurrentlyRunning(false);
try { try {
jobRegisterService.scheduleJob(jobDetails); jobRegisterService.scheduleJob(jobDetails);
} catch (JobIsNotFoundOrNotEnabledException e) { } catch (JobIsNotFoundOrNotEnabledException e) {
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
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.WebApplicationException;
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.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
*/
@Path("/v1/apiclients")
@Component
@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.")
@RequiredArgsConstructor
public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
requireSuperUser();
final List<Map<String, Object>> clients = credentialService.listClients();
return gson.toJson(clients);
}
@DELETE
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String deactivate(@PathParam("id") final Long id) {
requireSuperUser();
credentialService.setActive(id, false);
return gson.toJson(Map.of("resourceId", id, "isActive", false));
}
@POST
@Path("/{id}/reactivate")
@Produces(MediaType.APPLICATION_JSON)
public String reactivate(@PathParam("id") final Long id) {
requireSuperUser();
credentialService.setActive(id, true);
return gson.toJson(Map.of("resourceId", id, "isActive", true));
}
private AppUser requireSuperUser() {
final AppUser user = context.authenticatedUser();
if (!user.hasAnyPermission("ALL_FUNCTIONS")) {
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON)
.build());
}
return user;
}
}
@@ -0,0 +1,243 @@
/**
* 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.infrastructure.security.apiclient;
import jakarta.annotation.PostConstruct;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user
* login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this
* fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at
* the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate
* stays open for the specific path, and this class is the thing that actually decides "yes" or "no".
*
* {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via
* the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean
* {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary
* tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not
* an oversight: a key is a caller's identity, independent of which bank's data a given request is asking
* for, so one minted key must keep working against every tenant that exists now AND every one
* provisioned later. A copy of this table inside each bank's own tenant database would need a manual
* re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing
* it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes
* the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to
* the right bank's database) -- this class only ever touches the one central table.
*
* Security properties, deliberately:
* <ul>
* <li>Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked
* database backup can never be turned back into a usable credential -- the same reason Fineract itself
* never stores a user's plaintext password.</li>
* <li>The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is
* not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and
* deactivate the old.</li>
* <li>{@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key
* doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker
* cannot use the response to tell a wrong key apart from a valid-but-blocked one.</li>
* <li>The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a
* small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave
* {@code allowedIps} null for that key rather than trying to enumerate every address.</li>
* </ul>
*/
@Component
@RequiredArgsConstructor
public class ApiClientCredentialService {
private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key.";
// Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a
// session token, appropriate for a credential that never expires on its own.
private static final int KEY_ENTROPY_BYTES = 32;
@Qualifier("hikariTenantDataSource")
private final DataSource tenantStoreDataSource;
private final SecureRandom secureRandom = new SecureRandom();
// Built once from the qualified DataSource in @PostConstruct rather than injected directly as a
// JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields,
// and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is.
private JdbcTemplate tenantStoreJdbc;
@PostConstruct
void init() {
this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource);
}
/**
* Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's
* display name (for the caller to log/attribute the request to) and records this call -- including
* which tenant it was made against -- as the key's most recent use. On any failure, throws a clean
* 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP.
*/
public String authenticate(final String rawApiKey, final String callerIp) {
if (StringUtils.isBlank(rawApiKey)) {
throw unauthorized();
}
final String hash = sha256Hex(rawApiKey);
final List<Map<String, Object>> rows = tenantStoreJdbc.queryForList("""
SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ?
""", hash);
if (rows.isEmpty()) {
throw unauthorized();
}
final Map<String, Object> row = rows.get(0);
if (!Boolean.TRUE.equals(row.get("is_active"))) {
throw unauthorized();
}
final String allowedIps = (String) row.get("allowed_ips");
if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) {
throw unauthorized();
}
final Long id = ((Number) row.get("id")).longValue();
tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?",
java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id);
return (String) row.get("client_name");
}
private boolean isIpAllowed(final String allowedIps, final String callerIp) {
if (StringUtils.isBlank(callerIp)) {
return false;
}
return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp));
}
/**
* Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value
* is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key
* authenticates against every tenant, regardless of which one the admin minting it happened to be
* logged into.
*/
public Map<String, Object> createClient(final String clientName, final String allowedIps, final Long createdByUserId) {
if (StringUtils.isBlank(clientName)) {
throw badRequest("clientName is required.");
}
final String rawKey = generateRawKey();
final String hash = sha256Hex(rawKey);
final String prefix = rawKey.substring(0, Math.min(12, rawKey.length()));
final Long id = tenantStoreJdbc.queryForObject("""
INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant)
VALUES (?,?,?,?,?,?)
RETURNING id
""", Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId,
currentTenantId());
final Map<String, Object> result = new LinkedHashMap<>();
result.put("id", id);
result.put("clientName", clientName);
result.put("apiKey", rawKey);
result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again.");
return result;
}
public List<Map<String, Object>> listClients() {
return tenantStoreJdbc.queryForList("""
SELECT id, client_name, api_key_prefix, is_active, allowed_ips,
created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant
FROM m_api_client ORDER BY created_date DESC
""");
}
public void setActive(final Long id, final boolean active) {
final int updated = tenantStoreJdbc.update(
"UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active,
active ? null : java.sql.Timestamp.from(Instant.now()), id);
if (updated == 0) {
throw notFound("No API client with id " + id);
}
}
private String currentTenantId() {
try {
return ThreadLocalContextUtil.getTenant().getTenantIdentifier();
} catch (final RuntimeException e) {
// Defensive only: every request through SecurityConfig's matcher chain has a tenant
// resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this
// should be unreachable in practice.
return null;
}
}
private String generateRawKey() {
final byte[] bytes = new byte[KEY_ENTROPY_BYTES];
secureRandom.nextBytes(bytes);
// URL-safe, no padding: safe to put directly in a header value with no further encoding.
return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String sha256Hex(final String raw) {
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
for (final byte b : hashBytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (final NoSuchAlgorithmException e) {
// SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports --
// this can only mean a broken JVM install, not a recoverable runtime condition.
throw new IllegalStateException("SHA-256 is unavailable in this JVM", e);
}
}
private WebApplicationException unauthorized() {
// Naming the tenant the caller sent is safe here -- it is the header value they themselves
// put on the request, not something this check reveals to them -- and it turns "why did my
// key fail" from a guess into something checkable: was this call even reaching the bank the
// caller thought it was?
final String tenantId = currentTenantId();
final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE;
return jsonError(401, message);
}
private WebApplicationException badRequest(final String message) {
return jsonError(400, message);
}
private WebApplicationException notFound(final String message) {
return jsonError(404, message);
}
private WebApplicationException jsonError(final int status, final String message) {
final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}";
return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,214 @@
/**
* 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.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Which top-nav menu sections a tenant is allowed to ever show, regardless of what any role inside
* that tenant grants itself.
*
* {@code HasPermissionDirective} on the frontend already hides a menu item a user's own role lacks --
* but a role carrying {@code ALL_FUNCTIONS} (a super user, or a role simply misconfigured to hold it,
* as the "Clerk" role was found to in two tenants this session) bypasses that check unconditionally.
* A bank's own admin screen for roles and permissions therefore cannot be trusted as the only gate on
* "does this bank ever get to see the HRM/NPA/Admin section" -- whoever ends up with ALL_FUNCTIONS in
* that tenant, today or after some future role edit, would still see it. This table is the ceiling
* that check now also has to clear.
*
* Reading it accepts either credential (see {@link #read} below) -- a bank's own user needs their own
* tenant's list to render their nav correctly, the same way they already read their own permissions,
* and the platform operator needs to read ANY tenant's current list before toggling it. Writing it
* accepts only the platform's own {@code X-Api-Key} + {@link ApiClientCredentialService} credential --
* the same mechanism {@code ClientAllAccountsApiResource} uses for third-party callers, but with no
* session fallback at all here. That is deliberate -- a bank's own super user must never be able to
* lift its own ceiling by granting themselves a permission from inside their own tenant, which is
* exactly what a normal {@code ALL_FUNCTIONS} gate would allow. The key is minted once via
* {@code POST /v1/apiclients} by the platform operator and never handed to a bank.
*/
@Path("/v1/tenant-menu-access")
@Component
@Tag(name = "Tenant Menu Access",
description = "Per-tenant cap on which top-nav menu sections can ever be shown, independent of any role's own permissions. Read by any logged-in user of the tenant; written only via a platform-operator API key.")
@RequiredArgsConstructor
public class TenantMenuAccessApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ApiClientCredentialService apiClientCredentialService;
private final TenantDetailsService tenantDetailsService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
// The whole, fixed set of top-nav menu codes this screen knows about -- see 0541 for how this
// list was seeded. A write is only ever a PATCH over this table's existing rows (see save()); a
// code outside this set cannot be created by the API, so a typo in a request body is silently a
// no-op rather than junk data the frontend would then have to guard against.
private static final Set<String> KNOWN_MENU_CODES = Set.of("MENU_INSTITUTION", "SIDEBAR_CFCFRMS", "MENU_TASKS",
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
/**
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
* Dual-mode read, same posture as {@code ClientAllAccountsApiResource}: an ordinary logged-in
* user reads their own tenant's list exactly like they already read their own permission list
* (no elevated permission required), or the platform operator reads ANY tenant's list with the
* API key, to know the current state before toggling it on their own control screen. Only the
* write side ({@link #save}) has no session fallback -- reading configuration is not sensitive,
* changing it is.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
public String read(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
if (StringUtils.isNotBlank(apiKey)) {
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
} else {
context.authenticatedUser();
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access");
final JsonArray out = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject o = new JsonObject();
o.addProperty("menuCode", (String) row.get("menu_code"));
o.addProperty("enabled", Boolean.TRUE.equals(row.get("enabled")));
out.add(o);
}
return gson.toJson(out);
}
/**
* Every tenant configured on this platform, for the operator's own tenant picker -- always the
* master list from the tenant-store database, never the one tenant the caller's own
* {@code Fineract-Platform-TenantId} header happened to resolve to. That header still has to name
* a real tenant for the request to reach this handler at all (see
* {@code TenantAwareBasicAuthenticationFilter}); which one is otherwise irrelevant here.
*/
@GET
@Path("/tenants")
@Produces(MediaType.APPLICATION_JSON)
public String listTenants(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
requireApiKey(apiKey, servletRequest);
final JsonArray out = new JsonArray();
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
final JsonObject o = new JsonObject();
o.addProperty("identifier", tenant.getTenantIdentifier());
o.addProperty("name", tenant.getName());
out.add(o);
}
return gson.toJson(out);
}
/**
* Sets which menu codes the tenant named in {@code Fineract-Platform-TenantId} may show, from the
* operator's own control screen. API key only -- see the class comment for why a normal session,
* even a super user's, is never accepted here.
*
* Body: {@code [{"menuCode": "MENU_HRM", "enabled": false}, ...]}. Only codes already in
* {@link #KNOWN_MENU_CODES} are written; anything else in the body is ignored rather than
* rejected, so the operator's screen can always send its full known set without first checking
* this deployment's exact backend version.
*/
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String save(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest,
final String body) {
requireApiKey(apiKey, servletRequest);
final JsonArray entries = gson.fromJson(body, JsonArray.class);
if (entries == null) {
throw apiError(400, "Request body must be a JSON array of {menuCode, enabled}.");
}
final Set<String> touched = new LinkedHashSet<>();
for (final var el : entries) {
final JsonObject entry = el.getAsJsonObject();
if (!entry.has("menuCode") || entry.get("menuCode").isJsonNull()) {
continue;
}
final String menuCode = entry.get("menuCode").getAsString();
if (!KNOWN_MENU_CODES.contains(menuCode)) {
continue;
}
final boolean enabled = entry.has("enabled") && !entry.get("enabled").isJsonNull() && entry.get("enabled").getAsBoolean();
jdbcTemplate.update("UPDATE m_tenant_menu_access SET enabled = ? WHERE menu_code = ?", enabled, menuCode);
touched.add(menuCode);
}
final JsonObject res = new JsonObject();
res.addProperty("tenant", ThreadLocalContextUtil.getTenant().getTenantIdentifier());
res.addProperty("updated", touched.size());
return gson.toJson(res);
}
/** No session fallback, unlike every other API-key-capable endpoint in this codebase -- see the class comment. */
private void requireApiKey(final String apiKey, final HttpServletRequest servletRequest) {
if (StringUtils.isBlank(apiKey)) {
throw apiError(403, "This endpoint is only reachable with a platform operator API key.");
}
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
}
private String resolveClientIp(final HttpServletRequest request) {
for (final String header : IP_HEADER_CANDIDATES) {
final String value = request.getHeader(header);
if (StringUtils.isNotBlank(value)) {
return value.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT; import jakarta.ws.rs.PUT;
@@ -200,6 +201,20 @@ public class OfficesApiResource {
return toApiJsonSerializer.serialize(result); return toApiJsonSerializer.serialize(result);
} }
@DELETE
@Path("{officeId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.")
@ApiResponse(responseCode = "200", description = "OK")
public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.deleteOffice(officeId) //
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return toApiJsonSerializer.serialize(result);
}
@PUT @PUT
@Path("/external-id/{externalId}") @Path("/external-id/{externalId}")
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.office.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.organisation.office.service.OfficeWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "OFFICE", action = "DELETE")
public class DeleteOfficeCommandHandler implements NewCommandSourceHandler {
private final OfficeWritePlatformService writePlatformService;
@Autowired
public DeleteOfficeCommandHandler(final OfficeWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteOffice(command.entityId(), command);
}
}
@@ -27,6 +27,8 @@ public interface OfficeWritePlatformService {
CommandProcessingResult updateOffice(Long officeId, JsonCommand command); CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
CommandProcessingResult deleteOffice(Long officeId, JsonCommand command);
CommandProcessingResult officeTransaction(JsonCommand command); CommandProcessingResult officeTransaction(JsonCommand command);
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command); CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
@@ -44,6 +44,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching; import org.springframework.cache.annotation.Caching;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
private final OfficeRepositoryWrapper officeRepositoryWrapper; private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final OfficeTransactionRepository officeTransactionRepository; private final OfficeTransactionRepository officeTransactionRepository;
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository; private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
// Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back
// to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a
// repository for every one of them just to count rows would be a lot of new wiring for a check
// that is fundamentally "does anything point at this row". A handful of raw counts up front cover
// the cases a user can actually resolve themselves (move the clients, reassign the staff); the
// long tail that is left is caught by the try/catch around the delete itself.
private final JdbcTemplate jdbcTemplate;
@Transactional @Transactional
@Override @Override
@@ -147,6 +155,73 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
} }
} }
/**
* Deletes an office outright -- distinct from every other office command, which only ever changes
* status or fields. There is no "closed" state for an office the way there is for a client or a
* savings account, and no command handler ever implemented this, so the UI's "Remove" action always
* failed with a generic error no matter how empty the office was; the endpoint it called never
* existed.
*
* Checked up front, in the order a user could actually act on them: sub-offices first (the deepest
* structural block), then staff, clients and groups (the day-to-day reason an office still has
* anything in it), then any AppUser still homed there. Everything else that references m_office --
* GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings
* transactions, and more -- is not spelled out here one table at a time; a real foreign-key
* violation on the delete itself is caught below and turned into one clear message instead. An
* office with real banking history behind it is not something this should make easy to remove either
* way, checked up front or not.
*/
@Transactional
@Override
@Caching(evict = {
@CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"),
@CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"),
@CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") })
public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) {
this.context.authenticatedUser();
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices",
"Move or remove its sub-offices before deleting this office.");
requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members",
"Reassign its staff to another office before deleting this office.");
requireNoneReference(officeId, "m_client", "office_id", "client", "clients",
"Transfer its clients to another office before deleting this office.");
requireNoneReference(officeId, "m_group", "office_id", "group", "groups",
"Transfer its groups to another office before deleting this office.");
requireNoneReference(officeId, "m_appuser", "office_id", "user", "users",
"Reassign the users homed at this office before deleting it.");
try {
this.officeRepositoryWrapper.deleteAndFlush(office);
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
} catch (final PersistenceException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(officeId) //
.withOfficeId(officeId) //
.build();
}
private void requireNoneReference(final Long officeId, final String table, final String column, final String singular,
final String plural, final String remedy) {
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
officeId);
if (count != null && count > 0) {
final String noun = count == 1 ? singular : plural;
throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced",
"Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId);
}
}
@Transactional @Transactional
@Override @Override
public CommandProcessingResult officeTransaction(final JsonCommand command) { public CommandProcessingResult officeTransaction(final JsonCommand command) {
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer, OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer, OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository, OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) { ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository, moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
applicationCurrencyRepository); applicationCurrencyRepository, jdbcTemplate);
} }
} }
@@ -25,6 +25,7 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.time.YearMonth; import java.time.YearMonth;
import java.util.List; import java.util.List;
@@ -32,6 +33,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData; import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService; import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService; import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -47,6 +49,7 @@ public class AmlCtrReportApiResource {
private final AmlCtrReportReadPlatformService readPlatformService; private final AmlCtrReportReadPlatformService readPlatformService;
private final AmlCtrReportGenerationService generationService; private final AmlCtrReportGenerationService generationService;
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer; private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<AmlCtrReportLineData> lineJsonSerializer;
@GET @GET
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -58,6 +61,17 @@ public class AmlCtrReportApiResource {
return this.toApiJsonSerializer.serialize(reports); return this.toApiJsonSerializer.serialize(reports);
} }
@GET
@Path("lines")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "The reportable lines behind the generated CTRs",
description = "Each line resolved to the account, customer, branch and date a FIU-IND filing names. Optional reportId narrows it to one month.")
public String retrieveLines(@QueryParam("reportId") final Long reportId) {
this.context.authenticatedUser();
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
}
@POST @POST
@Path("generate") @Path("generate")
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* One reportable line of a monthly CTR, carrying what the FIU-IND submission actually needs rather
* than only the ids the line is stored under.
*
* <p>
* The stored line keeps a client id and a transaction id; a filing needs the account, the customer,
* the branch and the date those ids stand for. They are joined here so the screen and the CSV are
* built from the report the bank generated, not from a second calculation that could disagree with
* it.
*
* <p>
* A connected-group line has no single transaction behind it - it is a month's worth of smaller
* cash movements by one customer that together cross the threshold - so its transaction id, account
* and date are null, and {@code connectedGroup} says why.
*/
public record AmlCtrReportLineData(Long id, Long reportId, String reportMonth, String transactionType, Long transactionId,
Long clientId, String clientName, String accountNo, String branchName, LocalDate transactionDate, String direction,
BigDecimal amount, boolean connectedGroup,
/**
* Account facts the Account_Detail file reports. They were being filled in with a constant
* opening date of 20/05/2018 and a status of ACTIVE for every row, which is a statement
* about the customer's account that nobody had checked.
*/
LocalDate accountOpenedOn, LocalDate accountClosedOn, String accountStatus,
/** Customer identifiers the Account_Person_Relation file reports. */
String ckycNumber, String clientExternalId, boolean individual) implements Serializable {}
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
/**
* Which way the money moved, for rules written about one direction only.
*
* <p>
* Without this every rule was matched on amount alone, so a ₹12,00,000 deposit raised
* "Frequent low cash WITHDRAWAL" alongside the genuine CTR hit — nine of the ten alerts on that one
* transaction were of that kind, and an analyst reading them cannot tell which one is real.
*/
public enum AmlDirection {
/** Money into the customer's account: a deposit, or a loan disbursal. */
CREDIT,
/** Money out of it: a withdrawal, or a loan repayment. */
DEBIT,
/** Not established — the rule's own direction filter, if it has one, then does not apply. */
UNKNOWN;
public boolean isCredit() {
return this == CREDIT;
}
public boolean isDebit() {
return this == DEBIT;
}
}
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
/**
* The kind of customer a rule is written about, for rules whose subject is the customer rather than
* the transaction.
*
* <p>
* Rules like "PEP account monitoring" and "charitable organisation cash withdrawal" name the entity
* in their own title, but nothing checked it: each was matched on amount alone and so fired on every
* ordinary customer who crossed the amount. Reporting every customer as a PEP is not the cautious
* reading - an analyst who sees a PEP alert on a salaried individual learns to ignore PEP alerts,
* and the one real politically exposed person is then missed among them.
*/
public enum AmlSubjectScope {
/** Politically exposed person, their relative or close associate. */
PEP,
/** Trust, society or other not-for-profit - cash withdrawals from these carry their own rule. */
CHARITABLE_ORG,
/**
* Dealers in precious metal and stones and the other businesses RBI names as higher risk,
* where a large transaction is worth reporting that would be routine for anyone else.
*/
HIGH_RISK_BUSINESS,
/**
* A recently opened account. A large transaction through an account that has barely been used
* is the classic mule pattern; the same transaction on a decade-old account is not remarkable.
*/
NEW_ACCOUNT;
public static AmlSubjectScope fromRule(final String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return AmlSubjectScope.valueOf(value.trim().toUpperCase());
} catch (final IllegalArgumentException e) {
// An unrecognised scope must not silently widen the rule back to everyone, so the
// caller treats null-from-blank and this case differently.
return null;
}
}
}
@@ -19,7 +19,9 @@
package org.apache.fineract.portfolio.aml.data; package org.apache.fineract.portfolio.aml.data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType; import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
@@ -45,9 +47,43 @@ public final class AmlTransactionContext {
* rules are simply skipped for this context rather than guessed at. * rules are simply skipped for this context rather than guessed at.
*/ */
private final BigDecimal windowAggregateAmount; private final BigDecimal windowAggregateAmount;
/**
* Which way the money moved. A rule written about withdrawals must not fire on a deposit, and
* "repayment of loan in cash" is not the same event as a disbursal.
*/
private final AmlDirection direction;
/**
* How many qualifying transactions make up {@link #windowAggregateAmount}. Structuring is a
* pattern of MANY small transactions, so the count is what separates it from one large one:
* a single ₹12,00,000 deposit is a CTR, not structuring, however the window total reads.
*/
private final int windowTransactionCount;
/**
* Every qualifying amount in the window, so a rule can count only what falls inside its own
* band. A single count cannot serve every rule: this client's window held four deposits — three
* small ones and one of ₹12,00,000 — which satisfied "three or more" for a structuring rule
* whose band none of the four sat in. Structuring is several transactions of a similar size,
* and only the amounts themselves can say whether that happened.
*/
private final List<BigDecimal> windowAmounts;
/**
* When the account this transaction moved through was opened. The "large transaction on a new
* account" rule is about the account rather than the customer, and an old customer can open a
* new account. Null where the caller could not establish it, in which case the customer's own
* activation date stands in.
*/
private final LocalDate subjectOpenedOn;
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount, public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) { String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
this(transactionType, transactionId, client, amount, currencyCode, occurredOn, cash, windowAggregateAmount, AmlDirection.UNKNOWN,
0, List.of(), null);
}
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount, AmlDirection direction,
int windowTransactionCount, List<BigDecimal> windowAmounts, LocalDate subjectOpenedOn) {
this.transactionType = transactionType; this.transactionType = transactionType;
this.transactionId = transactionId; this.transactionId = transactionId;
this.client = client; this.client = client;
@@ -56,6 +92,26 @@ public final class AmlTransactionContext {
this.occurredOn = occurredOn; this.occurredOn = occurredOn;
this.cash = cash; this.cash = cash;
this.windowAggregateAmount = windowAggregateAmount; this.windowAggregateAmount = windowAggregateAmount;
this.direction = direction;
this.windowTransactionCount = windowTransactionCount;
this.windowAmounts = windowAmounts == null ? List.of() : List.copyOf(windowAmounts);
this.subjectOpenedOn = subjectOpenedOn;
}
public AmlDirection getDirection() {
return direction;
}
public int getWindowTransactionCount() {
return windowTransactionCount;
}
public List<BigDecimal> getWindowAmounts() {
return windowAmounts;
}
public LocalDate getSubjectOpenedOn() {
return subjectOpenedOn;
} }
public AmlTransactionType getTransactionType() { public AmlTransactionType getTransactionType() {
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.aml.data.AmlDirection;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext; import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType; import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository; import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
@@ -113,12 +114,22 @@ public class AmlDomainServiceImpl implements AmlDomainService {
} }
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS); final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since); final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()) // Same side of the ledger as the transaction being observed: a deposit is not part of a
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); // withdrawal pattern, so the two are aggregated separately.
final List<SavingsAccountTransaction> sameDirection = recent.stream()
.filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()).toList();
// Each amount is carried through, not just the total: a rule has to be able to count what
// falls inside its own band. Three small deposits plus one of ₹12,00,000 satisfies "three or
// more" and a ₹10,00,000 total on the raw figures, which is how one large deposit came to be
// reported as structuring.
final List<BigDecimal> windowAmounts = sameDirection.stream().map(SavingsAccountTransaction::getAmount).toList();
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client, final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(), transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate); transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate,
depositDirection ? AmlDirection.CREDIT : AmlDirection.DEBIT, sameDirection.size(), windowAmounts,
transaction.getSavingsAccount().getActivationDate());
try { try {
ruleEvaluationService.evaluate(context); ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) { } catch (final RuntimeException e) {
@@ -134,12 +145,18 @@ public class AmlDomainServiceImpl implements AmlDomainService {
} }
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS); final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since); final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType) final List<LoanTransaction> repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList();
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add); final List<BigDecimal> windowAmounts = repayments.stream()
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).toList();
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
// A repayment takes money from the customer; a disbursal gives it to them. Rules such as
// "repayment of loan in cash" are about the first and must not fire on the second.
final AmlDirection direction = transaction.isRepaymentLikeType() ? AmlDirection.DEBIT : AmlDirection.CREDIT;
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client, final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(), transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate); transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, direction,
repayments.size(), windowAmounts, transaction.getLoan().getApprovedOnDate());
try { try {
ruleEvaluationService.evaluate(context); ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) { } catch (final RuntimeException e) {
@@ -20,8 +20,17 @@ package org.apache.fineract.portfolio.aml.service;
import java.util.List; import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData; import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
public interface AmlCtrReportReadPlatformService { public interface AmlCtrReportReadPlatformService {
List<AmlCtrReportData> retrieveAll(); List<AmlCtrReportData> retrieveAll();
/**
* The reportable lines, resolved to the account, customer and branch a filing names.
*
* @param reportId
* one month's report, or null for every generated month
*/
List<AmlCtrReportLineData> retrieveLines(Long reportId);
} }
@@ -18,13 +18,16 @@
*/ */
package org.apache.fineract.portfolio.aml.service; package org.apache.fineract.portfolio.aml.service;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData; import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport; import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository; import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -33,7 +36,40 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService { public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
/**
* Savings transaction type 1 is a deposit and 2 a withdrawal; a CTR names which way the cash
* moved, so the line says so rather than leaving the reader to infer it from the amount.
*/
private static final String LINES_SQL = """
SELECT l.id, l.ctr_report_id, r.report_month, l.transaction_type, l.transaction_id,
l.client_id, c.display_name AS client_name, l.amount, l.is_connected_group,
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
sa.activatedon_date AS account_opened_on,
sa.closedon_date AS account_closed_on,
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
WHEN 800 THEN 'MATURED' END AS account_status,
k.ckyc_number AS ckyc_number,
-- The customer's own reference for the filing: CKYC number if the record carries
-- one, else the CIF the bank knows them by. Never blank, because a relation row
-- that identifies nobody cannot be reconciled against the customer card.
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
COALESCE(c.legal_form_enum, 1) = 1 AS individual
FROM m_aml_ctr_report_line l
JOIN m_aml_ctr_report r ON r.id = l.ctr_report_id
JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id
LEFT JOIN m_savings_account_transaction t
ON t.id = l.transaction_id AND l.transaction_type = 'SAVINGS'
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
""";
private final AmlCtrReportRepository repository; private final AmlCtrReportRepository repository;
private final JdbcTemplate jdbcTemplate;
@Override @Override
public List<AmlCtrReportData> retrieveAll() { public List<AmlCtrReportData> retrieveAll() {
@@ -41,6 +77,29 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
.map(this::mapToData).collect(Collectors.toList()); .map(this::mapToData).collect(Collectors.toList());
} }
@Override
public List<AmlCtrReportLineData> retrieveLines(final Long reportId) {
final StringBuilder sql = new StringBuilder(LINES_SQL);
final List<Object> args = new ArrayList<>();
if (reportId != null) {
sql.append(" WHERE l.ctr_report_id = ? ");
args.add(reportId);
}
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual")), args.toArray());
}
private AmlCtrReportData mapToData(AmlCtrReport report) { private AmlCtrReportData mapToData(AmlCtrReport report) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(), return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn()); report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlDirection;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext; import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
@@ -54,6 +55,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private final AmlAlertRepository alertRepository; private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository; private final ClientKycRepository clientKycRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService; private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
private final AmlSubjectScopeResolver subjectScopeResolver;
@Override @Override
public List<AmlAlert> evaluate(AmlTransactionContext context) { public List<AmlAlert> evaluate(AmlTransactionContext context) {
@@ -81,6 +83,9 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
} }
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) { private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (!appliesToThisTransaction(rule, context)) {
return false;
}
final BigDecimal threshold = resolveThreshold(rule, context); final BigDecimal threshold = resolveThreshold(rule, context);
if (threshold == null) { if (threshold == null) {
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic // Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
@@ -91,14 +96,125 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
case THRESHOLD: case THRESHOLD:
return context.getAmount().compareTo(threshold) >= 0; return context.getAmount().compareTo(threshold) >= 0;
case STRUCTURING: case STRUCTURING:
return isStructuringHit(rule, context, threshold);
case VELOCITY: case VELOCITY:
return context.getWindowAggregateAmount() != null return isVelocityHit(rule, context, threshold);
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
default: default:
return false; return false;
} }
} }
/**
* Whether this rule is about this transaction at all, before any amount is compared.
*
* <p>
* Everything below was previously unchecked, so a rule matched on amount alone: one ₹12,00,000
* cash deposit raised ten alerts, of which one was the genuine CTR hit and nine — "frequent low
* cash WITHDRAWAL", "charitable organisation", "PEP account" and the rest — were about
* transactions this was not. An analyst who cannot tell which of ten alerts is real stops
* reading them, and that is how a real one gets missed.
*/
private boolean appliesToThisTransaction(AmlTransactionRule rule, AmlTransactionContext context) {
// A cash-reporting rule is about physical cash. A transfer or a cheque is traceable by
// definition and is not what CTR exists to catch.
if (rule.isCashOnly() && !context.isCash()) {
return false;
}
// Direction, where the rule names one. UNKNOWN means the caller could not establish it, so
// the filter is not applied rather than guessed at.
final String ruleDirection = rule.getDirection();
if (ruleDirection != null && context.getDirection() != AmlDirection.UNKNOWN
&& !ruleDirection.equalsIgnoreCase(context.getDirection().name())) {
return false;
}
// And who the rule is about, for rules named after an entity rather than an amount.
// PEP_ACCOUNT_MONITORING was raised against an ordinary salaried customer because nothing
// here asked whether that customer was politically exposed.
return subjectScopeResolver.matches(rule.getSubjectScope(), context);
}
/**
* Structuring: many transactions each deliberately kept below a reporting threshold, summing
* to something that would have been reportable had it been done at once.
*
* <p>
* Two things separate it from one large transaction, and neither was being checked. The count:
* a pattern needs several transactions, so a single deposit is never structuring however large.
* And the band: each transaction has to sit in the rule's own min/max window — a ₹12,00,000
* deposit is not an instance of "splitting deposits just below ₹50,000", which is exactly what
* that rule used to report it as.
*/
private boolean isStructuringHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
if (context.getWindowAggregateAmount() == null) {
return false;
}
if (!isWithinRuleBand(rule, context.getAmount())) {
return false;
}
// Counted and summed over the rule's own band, not over the window as a whole. A window
// holding three ordinary small deposits and one ₹12,00,000 satisfies "three or more" and a
// ₹10,00,000 total on the raw figures, and that is how one large deposit came to be
// reported as structuring: none of the four was in the band the rule describes.
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
if (!hasEnoughTransactions(rule, inBand.size())) {
return false;
}
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
}
/**
* Velocity: an unusual number of transactions in a short window, whatever each one is worth.
*
* <p>
* Like structuring it needs a count — one transaction is not a velocity — but unlike structuring
* the individual amounts are not confined to a band, since the concern is the frequency itself.
*/
private boolean isVelocityHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
if (context.getWindowAggregateAmount() == null) {
return false;
}
// Also confined to the band, for the same reason: "frequent LOW cash deposit" is about a run
// of small ones, and a single ₹12,00,000 is not an instance of it however often the account
// is used.
if (!isWithinRuleBand(rule, context.getAmount())) {
return false;
}
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
if (!hasEnoughTransactions(rule, inBand.size())) {
return false;
}
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
}
/**
* Whether this transaction sits inside the band the rule describes.
*
* <p>
* A rule with no band stated matches any amount, which is how these rules behaved before the
* columns existed and is what a rule that only names a window total still means.
*/
private boolean isWithinRuleBand(AmlTransactionRule rule, BigDecimal amount) {
final BigDecimal min = rule.getMinLimitAmount();
final BigDecimal max = rule.getMaxLimitAmount();
if (min != null && amount.compareTo(min) < 0) {
return false;
}
return max == null || amount.compareTo(max) <= 0;
}
/**
* Whether enough transactions have happened in the window to call this a pattern.
*
* <p>
* A rule that states no minimum keeps the old behaviour; the seeded pattern rules all state 3,
* because two large deposits in a month is ordinary business and RBI's own wording is
* "numerous".
*/
private boolean hasEnoughTransactions(AmlTransactionRule rule, int countInBand) {
final Integer minimum = rule.getMinTransactionCount();
return minimum == null || minimum <= 1 || countInBand >= minimum;
}
/** /**
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged * Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category * from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
@@ -0,0 +1,136 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDate;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.data.AmlSubjectScope;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
/**
* Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is
* written about.
*
* <p>
* Everything here is read from what the customer record already holds - the client type chosen at
* onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount,
* because the amount is what the rule's threshold is already for.
*/
@Service
@RequiredArgsConstructor
public class AmlSubjectScopeResolver {
/**
* Client types that make an account a charitable/not-for-profit one for the purposes of the
* cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are
* per-tenant and these are seeded per tenant.
*/
private static final Set<String> CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST");
/**
* Businesses RBI names as carrying higher money-laundering risk, where a large transaction is
* worth a second look that the same amount elsewhere would not be.
*/
private static final Set<String> HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT",
"DEALER IN PRECIOUS METALS", "CASINO");
/** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */
private static final int NEW_ACCOUNT_DAYS = 180;
private static final int HIGH_RISK_KYC_CATEGORY = 3;
private final ClientKycRepository clientKycRepository;
/**
* Whether a rule carrying this scope should look at this transaction at all.
*
* <p>
* A rule with no scope is about the transaction rather than the customer and applies to
* everyone, which is how these rules behaved before the column existed.
*/
public boolean matches(final String configuredScope, final AmlTransactionContext context) {
final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope);
if (scope == null) {
return true;
}
final Client client = context.getClient();
if (client == null) {
return false;
}
return switch (scope) {
case PEP -> isPoliticallyExposed(client);
case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES);
case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES);
case NEW_ACCOUNT -> isNewAccount(context);
};
}
/**
* There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that
* fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is
* what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not
* treated as a PEP - a rule that cannot identify its subject must raise nothing rather than
* report everyone.
*/
private boolean isPoliticallyExposed(final Client client) {
if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP")
|| codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) {
return true;
}
return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue)
.filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent();
}
private boolean isClientTypeIn(final Client client, final Set<String> wanted) {
final String type = client.clientType() == null ? null : client.clientType().getLabel();
final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel();
return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification);
}
/**
* Measured from the customer's activation date against the business date, not the wall clock,
* so a back-dated test and a back-dated correction both read the age the books read.
*/
private boolean isNewAccount(final AmlTransactionContext context) {
final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn()
: context.getClient().getActivationDate();
if (openedOn == null) {
return false;
}
return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate());
}
private boolean containsIgnoreCase(final Set<String> wanted, final String value) {
if (value == null) {
return false;
}
final String normalised = value.trim().toUpperCase();
return wanted.contains(normalised);
}
private boolean codeValueMatches(final String value, final String token) {
return value != null && value.trim().toUpperCase().contains(token);
}
}
@@ -64,9 +64,13 @@ public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationServ
return; return;
} }
// Moves the re-KYC due date onto the new category's cycle as well, so the customer is
// called back in on the schedule the escalated rating requires.
kyc.escalateRiskCategory(nextCategory); kyc.escalateRiskCategory(nextCategory);
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase, historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
context.authenticatedUser(), LocalDateTime.now())); context.authenticatedUser(), LocalDateTime.now()));
log.info("Client {} auto-escalated from risk category {} to {} on the {}rd cumulative STR case; re-KYC now due {}",
client.getId(), previousCategory, nextCategory, cumulativeCases, kyc.getExpiryDate());
} }
private Integer nextCategory(Integer currentCategory) { private Integer nextCategory(Integer currentCategory) {
@@ -36,14 +36,18 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/** /**
* Branch day register — the formal opening and closing of a branch's business day. * Branch day register — the formal opening and closing of a branch's business day.
@@ -59,6 +63,7 @@ import org.springframework.stereotype.Component;
@Path("/v1/branchday") @Path("/v1/branchday")
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource { public class BranchDayRegisterApiResource {
@@ -181,14 +186,37 @@ public class BranchDayRegisterApiResource {
// What the cashier's own denomination entries add up to for the day. Where this and the // What the cashier's own denomination entries add up to for the day. Where this and the
// expected figure agree, the day tallies and the closing figure is not in question. // expected figure agree, the day tallies and the closing figure is not in question.
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
// cross-check was being compared against every branch's counted cash at once, which makes it
// wrong at every office but the first. A row whose officer maps to no cashier is still
// counted, for the same reason as the withdrawal legs in bookBreakdown.
//
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
// timestamp is not.
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE txn_date = ? AND txn_type = 'DEPOSIT' WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
""", BigDecimal.class, Date.valueOf(businessDate))); AND """ + CountedCashLedger.REAL_TXN_ID + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?))
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE txn_date = ? AND txn_type = 'WITHDRAWAL' WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
""", BigDecimal.class, Date.valueOf(businessDate))); AND """ + CountedCashLedger.REAL_TXN_ID + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?))
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
result.put("denominationIn", countedIn); result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut); result.put("denominationOut", countedOut);
@@ -197,9 +225,15 @@ public class BranchDayRegisterApiResource {
// ── POST /v1/branchday/open ─────────────────────────────────────────────── // ── POST /v1/branchday/open ───────────────────────────────────────────────
// Transactional because opening a day is three separate writes -- the register row, its
// branch-total OPENING breakdown, and each teller's carried-forward OPENING_VAULT -- and a
// failure part-way through used to commit whatever had already run. A register left open with no
// OPENING_VAULT is not a cosmetic gap: the next close's carry-forward reaches past it to an older
// register instead, and the vault's opening balance silently regresses to a stale figure.
@POST @POST
@Path("open") @Path("open")
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Transactional
public String openDay(final String body) { public String openDay(final String body) {
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser); requireDayAuthority(currentUser);
@@ -212,11 +246,54 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations"); final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
requireCashMigrationBeforeFirstEverOpen(officeId);
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations, final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations,
openingDifferenceReason); openingDifferenceReason);
return gson.toJson(res); return gson.toJson(res);
} }
/**
* A branch's very first business day open is also the moment its Cash at Vault GL account is
* supposed to start agreeing with what is physically in the till - that is exactly what Migrate
* Cash Denomination exists to do (count the drawer, define the GL from that count, open the day).
* This plain endpoint has no denomination-reconciliation step of its own: it just records whatever
* lump sum or count it is given as the day's opening cash, with no requirement that it line up
* with the ledger. Used for a branch's first-ever open, that would let day-to-day cash tracking
* begin while the GL silently sits at whatever it already was (typically zero on a brand-new
* tenant) - a mismatch nothing downstream would ever catch, since nothing here compares the two.
*
* Only the very first open is blocked: once Migrate Cash Denomination has posted anything to the
* cash GL for this office (regardless of the amount - even a correctly counted zero still means
* it went through the reconciliation step), every subsequent day-to-day open/close on this screen
* is untouched, exactly as it already works.
*/
private void requireCashMigrationBeforeFirstEverOpen(final Long officeId) {
final Integer priorDays = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ?",
Integer.class, officeId);
if (priorDays != null && priorDays > 0) {
return;
}
final Map<String, Object> gl;
try {
gl = resolveCashGlAccount();
} catch (final PlatformDataIntegrityException e) {
// No Cash at Vault / Cash at Teller account configured for this tenant at all - nothing
// for this guard to reconcile against, so the plain open proceeds exactly as it always has.
return;
}
final Long glAccountId = ((Number) gl.get("id")).longValue();
final Integer postedEntries = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND office_id = ? AND reversed = false",
Integer.class, glAccountId, officeId);
if (postedEntries == null || postedEntries == 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.first.open.requires.cash.migration",
"This branch has never opened a business day before, and its " + gl.get("name")
+ " GL account has no opening balance yet. Use Migrate Cash Denomination to count the "
+ "vault and open the first business day together, instead of Open Day here.");
}
}
/** /**
* The actual open-day work, factored out of {@link #openDay(String)} so * The actual open-day work, factored out of {@link #openDay(String)} so
* {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it * {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it
@@ -447,6 +524,7 @@ public class BranchDayRegisterApiResource {
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
final AppUser currentUser) { final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
@@ -459,9 +537,15 @@ public class BranchDayRegisterApiResource {
// ── POST /v1/branchday/close ────────────────────────────────────────────── // ── POST /v1/branchday/close ──────────────────────────────────────────────
// Transactional for the same reason as openDay: the register UPDATE, the branch-total CLOSING
// breakdown and each teller's CLOSING_VAULT snapshot are separate writes. This database still
// carries the evidence of them committing independently -- a register whose closed_at stayed
// NULL while an orphaned CLOSING_VAULT snapshot survived, which later got summed alongside a
// second snapshot and doubled a vault's carried-forward opening balance.
@POST @POST
@Path("close") @Path("close")
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Transactional
public String closeDay(final String body) { public String closeDay(final String body) {
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser); requireDayAuthority(currentUser);
@@ -479,6 +563,16 @@ public class BranchDayRegisterApiResource {
// counted note-by-note wins over a typed-in total. // counted note-by-note wins over a typed-in total.
final BigDecimal closingCash = denominationTotal != null ? denominationTotal final BigDecimal closingCash = denominationTotal != null ? denominationTotal
: this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); : this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
// A close needs a figure. Without one the row commits with closing_cash and cash_difference
// NULL, and every carry-forward lookup keys on "closing_cash IS NOT NULL" -- so the next open
// skips this register entirely and resurrects the day-before's note-for-note breakdown as
// tomorrow's opening cash, with no difference to explain and nothing on screen to suggest the
// chain broke. The UI always sends one; this endpoint is directly reachable and did not check.
if (closingCash == null) {
throw new PlatformDataIntegrityException("error.msg.branchday.closing.cash.required",
"Record what was counted before closing business day " + businessDate
+ " -- either a closing figure or a denomination breakdown.");
}
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root); final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
@@ -543,16 +637,46 @@ public class BranchDayRegisterApiResource {
blockingCashiers.add(name + " (" + String.join(", ", issues) + ")"); blockingCashiers.add(name + " (" + String.join(", ", issues) + ")");
} }
} }
// Overridden only by an explicit reason, and never silently.
//
// The block is the right default: a day closed over a drawer nobody counted is a day whose
// cash figure means nothing. But it cannot be absolute either. A cashier who reconciled on
// the wrong date, went on leave, or left the branch mid-shift leaves a day that can never be
// closed at all, and every day after it inherits the same block — while the screen offers a
// Force Close Day button that could not actually get past this throw, so pressing it
// returned the same refusal and read as a broken button.
//
// So a reason gets you through, and the names of the cashiers who were not reconciled are
// appended to it on the record. Somebody reading that day later sees both that it was forced
// and exactly whose cash was unaccounted for — which is more use than a day that cannot be
// closed and a button that does nothing.
String overrideReason = forceReason;
if (!blockingCashiers.isEmpty()) { if (!blockingCashiers.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", if (overrideReason == null || overrideReason.isBlank()) {
"Cannot close the day — the following cashiers have not been reconciled or settled: " throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled",
+ String.join("; ", blockingCashiers) "Cannot close the day — the following cashiers have not been reconciled or settled: "
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed."); + String.join("; ", blockingCashiers)
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day"
+ " can be closed, or record a reason to force the close.");
}
overrideReason = overrideReason + " [Forced over unreconciled cashiers: " + String.join("; ", blockingCashiers)
+ "]";
log.warn("Branch day {} for office {} force-closed over unreconciled cashiers: {}", businessDate, officeId,
String.join("; ", blockingCashiers));
} }
// A forced close is a forced close whether or not the date is also in the past.
final boolean forcedOverCashiers = !blockingCashiers.isEmpty();
// Closing a past date is allowed — otherwise a day left open could never be settled at all — // Closing a past date is allowed — otherwise a day left open could never be settled at all —
// but it must say why it was left open, and it is stamped as a force close. // but it must say why it was left open, and it is stamped as a force close.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); //
// Judged against the tenant's BUSINESS date, not the wall clock: a branch that finishes
// counting at 00:20 is still closing its own current day, and stamping that as a forced late
// close (with a mandatory "why was this left open" reason) both annoys the closer and
// destroys the one distinction this register exists for -- closed that evening versus closed
// nine days later. The same method already uses getBusinessLocalDate() for its own
// arithmetic; using two different notions of "today" in one close was the inconsistency.
final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean isLate = businessDate.isBefore(today); final boolean isLate = businessDate.isBefore(today);
if (isLate && (forceReason == null || forceReason.isBlank())) { if (isLate && (forceReason == null || forceReason.isBlank())) {
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required", throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
@@ -562,15 +686,31 @@ public class BranchDayRegisterApiResource {
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash"); final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null; final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
jdbcTemplate.update(""" // "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
// this write are two separate statements, so two managers pressing Close at once (or one
// double-click) both got past the check. The second UPDATE then overwrote the first's
// closing figure, closer and difference with no trace, and the two denomination writes
// interleaved into exactly the duplicate CLOSING rows saveDenominationDetail's
// delete-then-insert exists to prevent. Letting the database arbitrate makes the loser of
// that race fail cleanly instead.
final int closed = jdbcTemplate.update("""
UPDATE m_branch_day_register UPDATE m_branch_day_register
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?, SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ? book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
WHERE office_id = ? AND business_date = ? WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
""", """,
// Forced either way: a late close, or a close over a drawer nobody counted. The reason is
// stored whenever it was needed, not only when the date was in the past — a same-day
// close forced over an unreconciled cashier used to drop its reason on the floor, which
// left the one fact a reader would want to know about that day unrecorded.
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
bookCash, difference, isLate, isLate ? forceReason : null, remarks, bookCash, difference, isLate || forcedOverCashiers,
(isLate || forcedOverCashiers) ? overrideReason : null, remarks,
officeId, Date.valueOf(businessDate)); officeId, Date.valueOf(businessDate));
if (closed == 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
"Business day " + businessDate + " is already closed.");
}
final Long registerId = ((Number) found.get(0).get("id")).longValue(); final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations); saveDenominationDetail(registerId, "CLOSING", denominations);
@@ -583,7 +723,7 @@ public class BranchDayRegisterApiResource {
res.put("closingCash", closingCash); res.put("closingCash", closingCash);
res.put("bookCash", bookCash); res.put("bookCash", bookCash);
res.put("cashDifference", difference); res.put("cashDifference", difference);
res.put("forceClosed", isLate); res.put("forceClosed", isLate || forcedOverCashiers);
return gson.toJson(res); return gson.toJson(res);
} }
@@ -695,12 +835,30 @@ public class BranchDayRegisterApiResource {
* {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier * {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier
* added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see * added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see
* {@link VaultPositionService} for why the two are never allowed to mix. * {@link VaultPositionService} for why the two are never allowed to mix.
*
* Replaces whatever this exact (register, leg, teller) already held rather than adding to it.
* Without that, a second call for the same snapshot -- a retry, a re-submitted close after an
* earlier attempt stalled partway, {@link #snapshotVaultDenominations} running twice for the
* same close -- leaves the old rows sitting alongside the new ones. Nothing downstream
* deduplicates by denomination before summing, so a vault's carried-forward opening balance (or
* a branch's own opening/closing figure) silently doubles instead of being replaced, exactly the
* kind of self-inflicted "the vault has more cash than the whole branch's book figure" gap this
* table exists to prevent, not cause.
*/ */
private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId, private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId,
final List<Map<String, Object>> denominations) { final List<Map<String, Object>> denominations) {
if (denominations == null || denominations.isEmpty()) { if (denominations == null || denominations.isEmpty()) {
return; return;
} }
// The bare "? IS NULL" comparison (no column on either side to infer a type from) makes
// PostgreSQL's JDBC driver fail the whole statement with "could not determine data type of
// parameter" -- unlike "teller_id = ?", which gets its type from the teller_id column even
// when the bound value is null. Casting to BIGINT gives the standalone parameter a type of
// its own, independent of context.
jdbcTemplate.update("""
DELETE FROM m_branch_day_register_detail
WHERE register_id = ? AND leg = ? AND (teller_id = ? OR (teller_id IS NULL AND CAST(? AS BIGINT) IS NULL))
""", registerId, leg, tellerId, tellerId);
for (final Map<String, Object> row : denominations) { for (final Map<String, Object> row : denominations) {
final int value = (int) row.get("denominationValue"); final int value = (int) row.get("denominationValue");
final int count = (int) row.get("count"); final int count = (int) row.get("count");
@@ -909,11 +1067,32 @@ public class BranchDayRegisterApiResource {
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on // disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
// APPROVED alone silently excluded every completed withdrawal from this total, overstating // APPROVED alone silently excluded every completed withdrawal from this total, overstating
// the branch's expected cash by exactly what had actually been paid out that day. // the branch's expected cash by exactly what had actually been paid out that day.
// Scoped to this office through the cashier who paid it out. Unscoped, this summed EVERY
// branch's withdrawals into whichever branch happened to be closing: with 6-8 offices on
// these tenants, Head Office's expected cash was being reduced by cash another branch had
// paid over its own counter.
//
// Two id conventions have to be tolerated, because cashier_id here is NOT an m_cashiers id:
// these registers store the APPUSER id of the cashier who submitted (cashier_id = 4 =
// nitesh.cashier, while that user's m_cashiers row is id 1). Resolving it only as an
// m_cashiers id matched nothing and silently excluded every withdrawal from expected cash --
// reporting a phantom shortage of exactly what had been paid out. Accepting either reading
// keeps this correct whichever convention a given register follows.
//
// A row whose cashier is not set at all is still counted, matching the "OR office_id IS NULL"
// leniency the deposits query above applies: no cashier means no branch to attribute it to,
// and dropping it silently would understate the total instead.
final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject(""" final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request SELECT COALESCE(SUM(w.amount), 0) FROM m_clerk_withdrawal_request w
WHERE status = 'WITHDRAWN' WHERE w.status = 'WITHDRAWN'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? AND COALESCE(w.txn_date, CAST(w.submitted_at AS DATE)) = ?
""", BigDecimal.class, date)); AND (w.cashier_id IS NULL
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = w.cashier_id AND t.office_id = ?)
OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
WHERE c.id = w.cashier_id AND t.office_id = ?))
""", BigDecimal.class, date, officeId, officeId));
// Account closures pay cash over the same counter but live in their own register, so they // Account closures pay cash over the same counter but live in their own register, so they
// were missing from this total entirely -- the branch's expected cash stayed high by exactly // were missing from this total entirely -- the branch's expected cash stayed high by exactly
@@ -925,24 +1104,36 @@ public class BranchDayRegisterApiResource {
// since a closure takes nothing out of the drawer until the cashier hands it over (a // since a closure takes nothing out of the drawer until the cashier hands it over (a
// nil-balance closure never reaches a cashier at all, hence the closure_date fallback). // nil-balance closure never reaches a cashier at all, hence the closure_date fallback).
final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject(""" final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(cashier_disbursed_amount), 0) FROM m_clerk_closure_request SELECT COALESCE(SUM(cr.cashier_disbursed_amount), 0) FROM m_clerk_closure_request cr
WHERE status = 'CLOSED' WHERE cr.status = 'CLOSED'
AND COALESCE(CAST(disbursed_at AS DATE), closure_date) = ? AND COALESCE(CAST(cr.disbursed_at AS DATE), cr.closure_date) = ?
""", BigDecimal.class, date)); AND (cr.cashier_id IS NULL
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = cr.cashier_id AND t.office_id = ?)
OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
WHERE c.id = cr.cashier_id AND t.office_id = ?))
""", BigDecimal.class, date, officeId, officeId));
final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals); final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals);
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch. // Cash drawn from the vault to a cashier — more physical cash sitting in the branch. Scoped
// to this office by the vault it came out of, for the same reason as the withdrawals above.
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject(""" final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false WHERE ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
""", BigDecimal.class, date)); AND (ct.from_teller_id IS NULL
OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = ct.from_teller_id AND t.office_id = ?))
""", BigDecimal.class, date, officeId));
// Cash a cashier settled back to the vault at the end of the day. // Cash a cashier settled back to the vault at the end of the day, scoped to the vault it
// went back into.
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject(""" final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement SELECT COALESCE(SUM(cs.settlement_amount), 0) FROM m_cash_settlement cs
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false WHERE cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
""", BigDecimal.class, date)); AND (cs.to_teller_id IS NULL
OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = cs.to_teller_id AND t.office_id = ?))
""", BigDecimal.class, date, officeId));
final BigDecimal expected = opening.add(deposits).subtract(withdrawals); final BigDecimal expected = opening.add(deposits).subtract(withdrawals);
@@ -995,8 +1186,26 @@ public class BranchDayRegisterApiResource {
cashAccountId, cashAccountId); cashAccountId, cashAccountId);
} }
/**
* A caller-supplied officeId is checked against the caller's own office hierarchy, not taken on
* trust. Without that check, any user whose role name happens to contain "clerk"/"manager"/"head
* cashier" (see {@link #canOperateDay}) could open or close ANOTHER branch's business day, and
* read its register, close preview and cash-GL balance, just by naming its id in the request --
* the authority check confirmed they may operate A day, never WHOSE day.
* {@code validateAccessRights} is the same office-hierarchy gate the client and group write
* services already use, so a head-office user retains access to their branches' days and a
* branch user is confined to their own office and anything below it.
*/
private Long resolveOffice(final Long requested, final AppUser user) { private Long resolveOffice(final Long requested, final AppUser user) {
if (requested != null) { if (requested != null) {
final String hierarchy = jdbcTemplate
.query("SELECT hierarchy FROM m_office WHERE id = ?", (rs, n) -> rs.getString(1), requested).stream()
.findFirst().orElse(null);
if (hierarchy == null) {
throw new PlatformDataIntegrityException("error.msg.branchday.office.not.found",
"Office " + requested + " was not found.");
}
this.context.validateAccessRights(hierarchy);
return requested; return requested;
} }
return user.getOffice() != null ? user.getOffice().getId() : 1L; return user.getOffice() != null ? user.getOffice().getId() : 1L;
@@ -53,15 +53,28 @@ public class CashExchangeApiResource {
@POST @POST
@Transactional @Transactional
public String create(String body) { public String create(String body) {
// Gated like every sibling register in this flow (CREATE_CASHTRANSFER,
// CREATE_CASHSETTLEMENT, CREATE_CASHINTERCASHIER). Cash exchange had no check at all, so any
// authenticated user -- not just a cashier -- could write legs that move a drawer's
// denomination position. Permission seeded and granted to the existing cash-handling roles by
// migration 0539.
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHEXCHANGE");
JsonObject json = JsonParser.parseString(body).getAsJsonObject(); JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null; final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString() final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
: null; : null;
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null; final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
// Defaulted, never left null: every as-of-date position query filters
// "ce.exchange_date <= CAST(? AS DATE)", which is false for NULL, so a null-dated exchange
// vanished from the cashier position and reconciliation reports while still counting in the
// undated stock query (DenominationBreakdownWriter.getCashierNetPosition). The drawer then
// read as holding the notes it had given away and none of the ones it had taken in. The
// sibling registers (cash transfer, settlement) already default the same way.
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull() final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
? LocalDate.parse(json.get("exchangeDate").getAsString()) ? LocalDate.parse(json.get("exchangeDate").getAsString())
: null; : DateUtils.getBusinessLocalDate();
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null; final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull() final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
? json.get("customerIdProof").getAsString() : null; ? json.get("customerIdProof").getAsString() : null;
@@ -153,6 +166,10 @@ public class CashExchangeApiResource {
@POST @POST
@Path("{id}/reverse") @Path("{id}/reverse")
public String reverse(@PathParam("id") Long id, String body) { public String reverse(@PathParam("id") Long id, String body) {
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
CashExchange exchange = repository.findById(id) CashExchange exchange = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id)); .orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) { if (Boolean.TRUE.equals(exchange.getIsReversed())) {
@@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -77,7 +79,21 @@ public class CashierDepositRequestApiResource {
// activity JsonArray, and a field of the same name reads as that variable at a glance. // 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 static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
// The temporary txn_id a pending deposit's denomination rows are tagged with (see
// savePendingDenomination) is this request's own id -- but m_savings_txn_denomination.txn_id has
// no reference-type discriminator, and a real m_savings_account_transaction id is drawn from the
// exact same small-integer range. Request #5 and transaction #5 coexisting is the ordinary case,
// not a rare one, so approve()'s "WHERE txn_id = <this request's id>" UPDATE (and reject()'s
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
// without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
// band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
// -- the cash is physically in the drawer already, approval or not.
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper; private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -132,6 +148,7 @@ public class CashierDepositRequestApiResource {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero"); throw apiError(400, "Amount must be greater than zero");
} }
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
validateDenominationTotal(denominationData, amount); validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue); validateDdLimit(ddFaceValue);
@@ -452,13 +469,15 @@ public class CashierDepositRequestApiResource {
} }
// Denomination was already saved at submit time (cashier physically received cash then). // Denomination was already saved at submit time (cashier physically received cash then).
// Now update the temporary txn_id (request id) to the real Fineract transaction id. // Now update the temporary txn_id (offset request id -- see PENDING_DEPOSIT_TXN_ID_OFFSET) to
// parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry). // the real Fineract transaction id. parseTxnIdToLong handles both decimal (savings/current)
// and hex (GL journal entry).
if (txnId != null) { if (txnId != null) {
final Long realTxnId = parseTxnIdToLong(txnId); final Long realTxnId = parseTxnIdToLong(txnId);
if (realTxnId != null) { if (realTxnId != null) {
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, id); "UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId,
id + PENDING_DEPOSIT_TXN_ID_OFFSET);
} }
} }
@@ -510,9 +529,10 @@ public class CashierDepositRequestApiResource {
throw apiError(409, "This deposit request was already actioned by another manager."); throw apiError(409, "This deposit request was already actioned by another manager.");
} }
// Reverse denomination — cashier physically returned cash to customer on rejection. // Reverse denomination — cashier physically returned cash to customer on rejection. The
// The temporary denomination records (saved at submit time with txn_id = request id) are removed. // temporary denomination records (saved at submit time with txn_id = offset request id -- see
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id); // PENDING_DEPOSIT_TXN_ID_OFFSET) are removed.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED")); return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
} }
@@ -664,7 +684,13 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString()) .withJson(payload.toString())
.build(); .build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); // The deposit register raised its own voucher at submission and attaches the till and
// member sides to it on approval, so the savings command must not raise a second one over
// the same money -- exactly as the GL branch of this register already does below. Without
// this, one deposit appears twice on the cashier's received report and doubles the day's
// cash taken in.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null; return result.getResourceId() != null ? result.getResourceId().toString() : null;
} }
@@ -740,7 +766,11 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString()) .withJson(payload.toString())
.build(); .build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); // Suppressed for the same reason as the savings path above. Current accounts raise no
// voucher of their own today, so this changes nothing now; it is here so that the day one
// is added, this register does not silently start double-counting again.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null; return result.getResourceId() != null ? result.getResourceId().toString() : null;
} }
@@ -865,7 +895,7 @@ public class CashierDepositRequestApiResource {
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
""", """,
requestId, savingsAccountId, accountNo, clientId, denomId, requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count), value, count, BigDecimal.valueOf((long) value * count),
cashierId, cashierUsername, paymentTypeName cashierId, cashierUsername, paymentTypeName
); );
@@ -23,12 +23,14 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -44,6 +46,10 @@ public class CashInterCashierApiResource {
private final CashInterCashierDetailRepository detailRepository; private final CashInterCashierDetailRepository detailRepository;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final CashierRoleHelper cashierRoleHelper; private final CashierRoleHelper cashierRoleHelper;
private final JdbcTemplate jdbcTemplate;
// The same figure the Cashier Position screen shows, so a cashier cannot hand on notes the
// report says they are not holding -- CashSettlementApiResource depends on it the same way.
private final CashReportApiResource cashReportApiResource;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
@POST @POST
@@ -85,6 +91,21 @@ public class CashInterCashierApiResource {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty", throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
"At least one denomination with a positive count is required."); "At least one denomination with a positive count is required.");
} }
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
// stock and settlement validates the cashier's; this register validated neither, so a cashier
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
// for ever, on every future date: the receiver's position, reconciliation and settlement
// allowance all rise by ₹1,00,000 of notes that do not exist, and the sender's reads
// -195x₹500 with nothing to net it back.
validateCashierHoldsDenominations(fromCashierId, denominations, transferDate);
// Both cashiers must sit under the same office. Nothing stopped a cross-branch inter-cashier
// transfer, which moves physical cash between two branches with no vault leg on either side
// -- so one branch's day-close is short and the other's is over, with no record of a transfer
// between them anywhere in either branch's register.
validateSameOffice(fromCashierId, toCashierId);
final BigDecimal total = sumTotal(denominations); final BigDecimal total = sumTotal(denominations);
CashInterCashier header = new CashInterCashier(); CashInterCashier header = new CashInterCashier();
@@ -206,6 +227,72 @@ public class CashInterCashierApiResource {
return false; return false;
} }
/**
* Same check, same source of truth and same message shape as
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
* position is left alone rather than refused, matching the settlement path: a cashier with no
* recorded position yet is a setup state, not an overdraft.
*/
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
final LocalDate transferDate) {
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
transferDate.toString());
@SuppressWarnings("unchecked")
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows == null || heldRows.isEmpty()) {
return;
}
final Map<Long, Long> held = new java.util.HashMap<>();
for (final Map<String, Object> row : heldRows) {
final Object id = row.get("denominationId");
final Object count = row.get("count");
if (id instanceof Number && count instanceof Number) {
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
}
}
for (final JsonElement el : denominations) {
final JsonObject item = el.getAsJsonObject();
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 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 long available = held.getOrDefault(denominationId, 0L);
if (count > available) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.insufficient.cashier.denomination",
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
+ ", transferring " + count + ". Check the Cashier Position report first.");
}
}
}
/** Both cashiers must belong to the same office, via their own teller. */
private void validateSameOffice(final Long fromCashierId, final Long toCashierId) {
final List<Map<String, Object>> offices = jdbcTemplate.queryForList("""
SELECT c.id AS cashier_id, t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
WHERE c.id IN (?, ?)
""", fromCashierId, toCashierId);
if (offices.size() < 2) {
// One of them has no teller/office resolvable -- leave it to the existing party checks
// rather than inventing a new failure here.
return;
}
final Object first = offices.get(0).get("office_id");
final Object second = offices.get(1).get("office_id");
if (first != null && second != null && !first.equals(second)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cross.office",
"Both cashiers must be in the same branch. Cash moving between branches has to go through"
+ " each branch's own vault, not a direct cashier-to-cashier transfer.");
}
}
private void saveDetails(Long interCashierId, JsonArray items) { private void saveDetails(Long interCashierId, JsonArray items) {
for (JsonElement el : items) { for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject(); JsonObject item = el.getAsJsonObject();
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -89,6 +90,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' " + "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) " + "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -118,6 +120,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' " + "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) " + "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -259,6 +262,14 @@ public class CashReconciliationApiResource {
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray() final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray(); ? json.getAsJsonArray("denominations") : new JsonArray();
// Expected counts come from the server's own computation, never from the request body.
// Reading the client's expectedCount made the whole reconciliation self-certifying: a cashier
// Rs 40,000 short could post expectedCount == physicalCount on every row, and the record
// stored variance 0 / status OK. That row then satisfied the mandatory reconciliation
// precondition on settlement, so the shortage never surfaced anywhere in the system. The
// physical count is the cashier's to declare; what the books expect is not.
final Map<Long, Long> serverExpected = expectedCountsByDenomination(cashierId, reconDate);
BigDecimal expectedTotal = BigDecimal.ZERO; BigDecimal expectedTotal = BigDecimal.ZERO;
BigDecimal physicalTotal = BigDecimal.ZERO; BigDecimal physicalTotal = BigDecimal.ZERO;
List<CashReconciliationDetail> details = new ArrayList<>(); List<CashReconciliationDetail> details = new ArrayList<>();
@@ -266,7 +277,10 @@ public class CashReconciliationApiResource {
for (JsonElement el : denominations) { for (JsonElement el : denominations) {
JsonObject item = el.getAsJsonObject(); JsonObject item = el.getAsJsonObject();
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0; int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0; final Long denomIdForExpected = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
int expectedCount = denomIdForExpected != null
? serverExpected.getOrDefault(denomIdForExpected, 0L).intValue() : 0;
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0; int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
int varianceCount = physicalCount - expectedCount; int varianceCount = physicalCount - expectedCount;
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount); BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
@@ -348,6 +362,30 @@ public class CashReconciliationApiResource {
rs.getLong("total_count") rs.getLong("total_count")
}; };
/**
* The server's own expected note/coin counts for a cashier as of a date, keyed by denomination
* id. Delegates to {@link #getExpected} rather than re-deriving the eight legs: two
* implementations of "what should this drawer hold" drift apart, and the one place that matters
* is the record a settlement is later allowed on the strength of.
*/
private Map<Long, Long> expectedCountsByDenomination(final Long cashierId, final LocalDate asOfDate) {
final Map<Long, Long> expected = new java.util.HashMap<>();
final JsonObject computed = JsonParser.parseString(getExpected(cashierId, asOfDate.toString())).getAsJsonObject();
if (!computed.has("denominations") || !computed.get("denominations").isJsonArray()) {
return expected;
}
for (final JsonElement el : computed.getAsJsonArray("denominations")) {
final JsonObject row = el.getAsJsonObject();
if (!row.has("denominationId") || row.get("denominationId").isJsonNull()) {
continue;
}
final long count = row.has("expectedCount") && !row.get("expectedCount").isJsonNull()
? row.get("expectedCount").getAsLong() : 0L;
expected.merge(row.get("denominationId").getAsLong(), count, Long::sum);
}
return expected;
}
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) { private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
for (Object[] row : rows) { for (Object[] row : rows) {
Long denomId = (Long) row[0]; Long denomId = (Long) row[0];
@@ -13,9 +13,11 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -24,6 +26,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashreports") @Path("/v1/cashreports")
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
public class CashReportApiResource { public class CashReportApiResource {
@@ -34,6 +37,58 @@ public class CashReportApiResource {
private final VaultPositionService vaultPositionService; private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
/**
* Excludes counter breakdowns that must not count towards a drawer.
*
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
* breakdown behind and went on inflating the drawer for good. Every other leg of the cash
* position (vault transfers, settlements, inter-cashier legs, exchanges) already guards against
* its own reversal flag; this is the same guard for the counter, read off the transaction the
* breakdown belongs to.
*
* <p>
* Two things are excluded, and they are different failures:
*
* <ul>
* <li>a breakdown whose transaction is reversed -- the money came back, so the notes did too;
* <li>a breakdown keyed by a millisecond timestamp rather than a transaction id. Some earlier
* code wrote {@code System.currentTimeMillis()} into txn_id, leaving rows that belong to no
* transaction at all. This database holds ₹7.29 lakh of them, and because a cashier's position
* is cumulative they were being carried forward for ever: the Closing Cash Report opened Rohit
* Dubey's drawer at ₹9,99,106 when the teller's own GL held ₹8,070.
* </ul>
*
* <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* reads the same on all four.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
// such transactions in this database were carrying ₹1.68 lakh that never existed.
//
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
// everything counted after it) out of the cashier's position.
//
// Compared per transaction inside a correlated subquery rather than as a join, so a
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
/** /**
* Day book — every voucher raised on one date, in one list, newest first. * Day book — every voucher raised on one date, in one list, newest first.
* *
@@ -72,7 +127,11 @@ public class CashReportApiResource {
// Cashier deposits — money in, so the credit side. // Cashier deposits — money in, so the credit side.
rows.addAll(jdbcTemplate.query(""" rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type, SELECT r.voucher_no,
-- Same rule as the withdrawal register below: an approved deposit is cash in
-- the drawer, a pending one is a request nobody has taken money for yet.
CASE WHEN r.status IN ('APPROVED', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
'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.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.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
r.submitted_at, r.actioned_at, r.submitted_at, r.actioned_at,
@@ -84,13 +143,31 @@ public class CashReportApiResource {
// Clerk withdrawals — money out, so the debit side. // Clerk withdrawals — money out, so the debit side.
rows.addAll(jdbcTemplate.query(""" rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type, SELECT r.voucher_no,
-- Filed by what happened to the money, not by who typed the request. Once the
-- notes are handed over it is the cashier's drawer that is short, so the row
-- belongs in their section; while it is still pending or was rejected no cash
-- has moved and it is the clerk's outstanding request.
CASE WHEN r.status IN ('WITHDRAWN', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
'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.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, COALESCE(paid.officer_username, r.clerk_name) AS handled_by,
r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at, r.submitted_at, r.actioned_at,
""" + verifyCols + """ """ + verifyCols + """
FROM m_clerk_withdrawal_request r FROM m_clerk_withdrawal_request r
""" + verifyJoin + """ """ + verifyJoin + """
-- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it
-- is filed under whoever's cash actually moved. Sectioning it by the maker put
-- every counter payment in the clerk's column and left the cashier's own section
-- short by exactly what they had paid out -- the one figure their drawer is
-- checked against. The payer is read from the denomination breakdown, which is
-- written when the cash is handed over, and the By column names them too.
LEFT JOIN LATERAL (
SELECT d.officer_username FROM m_savings_txn_denomination d
WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT)
AND d.officer_username IS NOT NULL
LIMIT 1) paid ON TRUE
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate)); """, this::mapDayBookRow, txnDate));
@@ -108,7 +185,12 @@ public class CashReportApiResource {
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier // disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
// step to reach. // step to reach.
rows.addAll(jdbcTemplate.query(""" rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no, SELECT r.voucher_no,
-- A closure is paid out at the counter, so it is the cashier's work.
-- This column was missing entirely and the row mapper reads it unconditionally,
-- so every CLOSURE row threw instead of appearing.
'CASHIER' AS section,
'DR' AS side, 'CLOSURE' AS kind, r.account_no,
'SAVINGS' AS account_type, r.client_name AS party, 'SAVINGS' AS account_type, r.client_name AS party,
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount, COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
r.payment_type_name, r.description, r.status, r.payment_type_name, r.description, r.status,
@@ -125,7 +207,7 @@ public class CashReportApiResource {
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a // 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. // bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query(""" rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, SELECT r.voucher_no, 'CLERK' AS section,
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side, 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.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.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
@@ -137,6 +219,125 @@ public class CashReportApiResource {
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE) WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate)); """, this::mapDayBookRow, txnDate));
// Cash taken at the counter for a Demand Draft — a receipt, so the credit side.
//
// The Sponsor DD register is not one of the three above and has no voucher_no of its own, so
// the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a
// day book with an empty CR column and no sign of the money. The voucher is joined the other
// way round, from m_voucher.reference_id, which is where the link actually lives.
//
// Only the cash-funded drafts appear, and only those the cashier took the notes for
// directly: an account-debit or cheque draft never crosses the counter, and one funded from
// a cashier deposit request is already listed above under that register's own row.
rows.addAll(jdbcTemplate.query("""
SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind,
NULL AS account_no, 'GL' AS account_type,
COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party,
r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount,
'CASH' AS payment_type_name,
CONCAT('DD for ', r.beneficiary_name) AS description,
r.status, mk.username AS handled_by, ck.username AS approved_by,
CAST(r.id AS VARCHAR) AS txn_id,
r.created_date AS submitted_at, r.checker_date AS actioned_at,
""" + verifyCols + """
FROM m_sponsor_dd_issue_request r
LEFT JOIN m_appuser mk ON mk.id = r.maker_id
LEFT JOIN m_appuser ck ON ck.id = r.checker_id
LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST'
AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%'
""" + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """
WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Everything else that moved money on this date and is not one of the registers above:
// interest posted, a charge collected from a balance, a counter deposit or withdrawal, an
// escheat, a manual journal.
//
// Read from the voucher register rather than from another set of source tables, because
// that is the one place every posting lands whatever raised it — including the nightly jobs,
// which have no request row anywhere to read them from. Without this the officer's sheet
// showed only what somebody had filed a request for, and a day of system postings against
// members' accounts was invisible on the very page meant to account for the day.
//
// The registers above are excluded by reference type, so a voucher already listed as a
// deposit or a withdrawal request cannot appear twice under a different heading.
//
// Status is reported as COMPLETED: a voucher only exists once its posting has committed, so
// there is no pending state to represent — unlike a request, which can sit unactioned.
rows.addAll(jdbcTemplate.query("""
SELECT v.voucher_no,
-- A nightly batch belongs to the day it worked on, not the minute it ran.
--
-- A voucher's created_at is a wall-clock instant, while the transaction it
-- posts and the journal entry behind it both carry the business date. So the
-- interest job that fires at 00:10 stamps its vouchers with tomorrow while
-- the ledger says yesterday — and the day it actually belongs to had already
-- been counted, reported and signed off hours earlier.
--
-- Rather than re-open a closed day, such a voucher is listed on the day it
-- surfaces, under its own heading, saying which day's work it is. The date is
-- taken from the transaction it posts; where there is none — a manual journal
-- keyed straight to the ledger — an early-hours timestamp is treated the same
-- way, because nobody is at a counter at 3am.
CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
< CAST(v.created_at AS DATE)
OR (v.savings_txn_id IS NULL
AND EXTRACT(HOUR FROM v.created_at) < 6)
THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section,
-- Which day's work this is, for the heading on the prior-day block.
COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date,
CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side,
v.voucher_type AS kind,
-- The member's own account number, never a voucher line's — a line carries a
-- GL code there, and showing 500201 in an Account column reads as an account
-- that does not exist. A journal with no member behind it shows none.
sa.account_no,
NULL AS account_type,
COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party,
v.amount,
NULL AS payment_type_name,
-- The two sides in one phrase, which is what a journal voucher is read as.
CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description,
'COMPLETED' AS status,
v.created_by_username AS handled_by,
-- Who checked it, if anybody has. Not aliased from verifyCols below, which
-- selects verified_by_name under its own name for the verification panel --
-- the row mapper reads both columns, so each has to be present exactly once.
vv.verified_by_name AS approved_by,
CAST(v.savings_txn_id AS VARCHAR) AS txn_id,
v.created_at AS submitted_at, v.created_at AS actioned_at,
""" + verifyCols + """
FROM m_voucher v
-- Either link: savings_txn_id is stamped by a step after the voucher is written
-- and is empty on every interest posting, where reference_id already carries the
-- same transaction id. Without the fallback the prior-day test never fires on the
-- nightly postings it was written for.
LEFT JOIN m_savings_account_transaction t
ON t.id = COALESCE(v.savings_txn_id,
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
THEN v.reference_id END)
LEFT JOIN m_savings_account sa ON sa.id = t.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
LEFT JOIN LATERAL (
SELECT party_name, account_no FROM m_voucher_line
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
ORDER BY line_no LIMIT 1) dr ON TRUE
LEFT JOIN LATERAL (
SELECT party_name, account_no FROM m_voucher_line
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
ORDER BY line_no LIMIT 1) cr ON TRUE
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = v.voucher_no
WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE)
AND (v.reference_type IS NULL OR v.reference_type NOT IN (
'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST',
'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST',
'SPONSOR_DD_ISSUE_REQUEST'))
""", this::mapDayBookRow, txnDate));
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be // 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. // 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 sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
@@ -145,6 +346,10 @@ public class CashReportApiResource {
final List<Map<String, Object>> filtered = new ArrayList<>(); final List<Map<String, Object>> filtered = new ArrayList<>();
BigDecimal creditTotal = BigDecimal.ZERO; BigDecimal creditTotal = BigDecimal.ZERO;
BigDecimal debitTotal = BigDecimal.ZERO; BigDecimal debitTotal = BigDecimal.ZERO;
// Kept apart from the day's own totals so a reader can see the overnight batch without it
// silently joining figures that were already reported and signed.
BigDecimal priorCreditTotal = BigDecimal.ZERO;
BigDecimal priorDebitTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) { for (final Map<String, Object> row : rows) {
if (sideFilter != null && !sideFilter.equals(row.get("side"))) { if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
@@ -163,8 +368,12 @@ public class CashReportApiResource {
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting // 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. // both states cannot double-count it; only 'CLOSED' is unique to that register.
final String rowStatus = (String) row.get("status"); final String rowStatus = (String) row.get("status");
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) // A batch that posted against yesterday is listed today but is not today's money: it
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) { // was already earned, and the day it belongs to has been counted and signed off. Adding
// it to this day's totals would make them disagree with the ledger for either date.
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount"); final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) { if (amount != null) {
if ("CR".equals(row.get("side"))) { if ("CR".equals(row.get("side"))) {
@@ -174,6 +383,16 @@ public class CashReportApiResource {
} }
} }
} }
if (priorDay) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
if ("CR".equals(row.get("side"))) {
priorCreditTotal = priorCreditTotal.add(amount);
} else {
priorDebitTotal = priorDebitTotal.add(amount);
}
}
}
} }
filtered.sort((a, b) -> { filtered.sort((a, b) -> {
@@ -195,15 +414,45 @@ public class CashReportApiResource {
result.put("date", txnDate); result.put("date", txnDate);
result.put("rows", filtered); result.put("rows", filtered);
result.put("count", filtered.size()); result.put("count", filtered.size());
// Counter figures: the credit and debit the customer side of the day adds up to.
result.put("creditTotal", creditTotal); result.put("creditTotal", creditTotal);
result.put("debitTotal", debitTotal); result.put("debitTotal", debitTotal);
result.put("netTotal", creditTotal.subtract(debitTotal)); result.put("netTotal", creditTotal.subtract(debitTotal));
// The overnight batch, reported separately: this is yesterday's work surfacing today.
result.put("priorCreditTotal", priorCreditTotal);
result.put("priorDebitTotal", priorDebitTotal);
return gson.toJson(result); return gson.toJson(result);
} }
/**
* A date column that only some of the union'd queries select.
*
* The six day-book queries do not share a column list, and adding a column to all of them so one
* can carry it would mean five queries stating a date they were handed as a parameter. Absent is
* reported as null rather than as an error.
*/
private String optionalDate(final java.sql.ResultSet rs, final String column) {
try {
final java.sql.Date value = rs.getDate(column);
return value == null ? null : value.toLocalDate().toString();
} catch (final java.sql.SQLException e) {
return null;
}
}
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
final Map<String, Object> row = new LinkedHashMap<>(); final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no")); row.put("voucherNo", rs.getString("voucher_no"));
// Which part of the day's work this row belongs to. The registers do not divide along
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
// where the entry came from, not who typed it.
row.put("section", rs.getString("section"));
// Which day's work the row is: normally the day being reported, but a nightly batch posts
// against the previous business date while surfacing on this one. Only the SYSTEM query
// selects it, so it is read defensively rather than added to five other queries that would
// all report the same date they were asked for.
row.put("effectiveDate", optionalDate(rs, "effective_date"));
row.put("side", rs.getString("side")); row.put("side", rs.getString("side"));
row.put("kind", rs.getString("kind")); row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no")); row.put("accountNo", rs.getString("account_no"));
@@ -241,41 +490,110 @@ public class CashReportApiResource {
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString()); row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
return row; return row;
} }
@GET @GET
@Path("closing") @Path("closing")
public String closing(@QueryParam("date") String date) { public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// Single query — conditional aggregation for deposit, withdrawal, net per denomination // A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, " // `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, " final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, " // The predicate goes through m_appuser because m_savings_txn_denomination records the acting
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " // user in officer_id, not the m_cashiers row -- the same join the position query uses.
+ "FROM m_savings_txn_denomination std " final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + " SELECT au.id FROM m_appuser au "
+ "WHERE std.txn_date = CAST(? AS DATE) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
+ "ORDER BY std.denomination_value DESC, dm.kind"; // Every note and coin that reached the drawer or left it, per denomination.
//
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
// counted against. So the vault allocation and the settlement back are included on the same
// footing as the counter work, and each row says where its notes came from.
//
// Cashier scoping differs per leg because each table records the party differently: the
// counter tables name the acting user, the cash tables name the cashier row.
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
// Counter work: what customers handed over and were paid.
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
// Drawn from the vault in the morning.
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
// Handed back to the vault at close.
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
long depositCount = rs.getLong("deposit_count"); final long vaultInCount = rs.getLong("vault_in_count");
long withdrawalCount = rs.getLong("withdrawal_count"); final long depositCount = rs.getLong("deposit_count");
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); final long withdrawalCount = rs.getLong("withdrawal_count");
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); final long settledOutCount = rs.getLong("settled_out_count");
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
Map<String, Object> row = new LinkedHashMap<>(); Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id")); row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value")); row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind")); row.put("kind", rs.getString("kind"));
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount); row.put("depositCount", depositCount);
row.put("depositAmount", depositAmount); row.put("depositAmount", depositAmount);
row.put("withdrawalCount", withdrawalCount); row.put("withdrawalCount", withdrawalCount);
row.put("withdrawalAmount", withdrawalAmount); row.put("withdrawalAmount", withdrawalAmount);
row.put("netCount", depositCount - withdrawalCount); row.put("settledOutCount", settledOutCount);
row.put("netAmount", depositAmount.subtract(withdrawalAmount)); row.put("settledOutAmount", settledOutAmount);
// What is left of this denomination: everything that came in, less everything that went
// out. This is the figure a teller counts their notes against, per note.
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount",
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
return row; return row;
}, txnDate); }, args.toArray());
BigDecimal depositTotal = BigDecimal.ZERO; BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO; BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -285,6 +603,7 @@ public class CashReportApiResource {
} }
// Cash exchange rows — separate section (denomination swap, net monetary = 0) // Cash exchange rows — separate section (denomination swap, net monetary = 0)
final String exchangeCashierFilter = scopedCashierId != null ? "AND ce.cashier_id = ? " : "";
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, " final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, " + "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, " + "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
@@ -294,10 +613,16 @@ public class CashReportApiResource {
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id " + "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.exchange_date = CAST(? AS DATE) " + "WHERE ce.exchange_date = CAST(? AS DATE) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind " + "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
+ "ORDER BY ced.denomination_value DESC, dm.kind"; + "ORDER BY ced.denomination_value DESC, dm.kind";
final List<Object> exArgs = new ArrayList<>();
exArgs.add(txnDate);
if (scopedCashierId != null) {
exArgs.add(scopedCashierId);
}
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> { final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
long receivedCount = rs.getLong("received_count"); long receivedCount = rs.getLong("received_count");
long givenCount = rs.getLong("given_count"); long givenCount = rs.getLong("given_count");
@@ -314,18 +639,135 @@ public class CashReportApiResource {
row.put("netCount", receivedCount - givenCount); row.put("netCount", receivedCount - givenCount);
row.put("netAmount", receivedAmount.subtract(givenAmount)); row.put("netAmount", receivedAmount.subtract(givenAmount));
return row; return row;
}, txnDate); }, exArgs.toArray());
// Cash taken over the counter for a Demand Draft.
//
// A cash DD asks for a denomination breakdown but does not insist on one, so most are
// recorded with none at all -- and the whole of this report is built from breakdowns. The
// money is real: the ledger holds it (Cash at Vault is debited when the notes are taken)
// and the cashier is holding the notes, yet the drawer figure omitted every rupee of it.
//
// Counted from the request itself rather than from a breakdown, so it appears whether or not
// the notes were itemised. Excluded are DDs funded through a cashier deposit request: that
// register counted the same notes already, and adding them here would count them twice.
final String ddCashierFilter = scopedCashierId != null ? "AND dd.maker_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
final String ddSql = "SELECT COALESCE(SUM(dd.face_value + COALESCE(dd.charges_amount, 0) "
+ "+ COALESCE(dd.gst_amount, 0)), 0) AS cash_total, COUNT(*) AS cash_count "
+ "FROM m_sponsor_dd_issue_request dd "
+ "WHERE dd.payment_method = 'CASH' AND dd.cashier_deposit_request_id IS NULL "
+ "AND dd.status <> 'REJECTED' "
+ "AND COALESCE(dd.issued_date, CAST(dd.created_date AS DATE)) = CAST(? AS DATE) " + ddCashierFilter;
final List<Object> ddArgs = new ArrayList<>();
ddArgs.add(txnDate);
if (scopedCashierId != null) {
ddArgs.add(scopedCashierId);
}
BigDecimal ddCashTotal = BigDecimal.ZERO;
long ddCashCount = 0;
try {
final Map<String, Object> ddRow = jdbcTemplate.queryForMap(ddSql, ddArgs.toArray());
ddCashTotal = (BigDecimal) ddRow.get("cash_total");
ddCashCount = ((Number) ddRow.get("cash_count")).longValue();
} catch (final RuntimeException e) {
// A tenant without the Sponsor DD tables still gets its closing report.
log.warn("Could not read cash Demand Draft receipts for {}", txnDate, e);
}
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate); result.put("date", txnDate);
result.put("rows", rows); result.put("rows", rows);
// The denomination-wise total, which is what the rows below add up to.
result.put("depositTotal", depositTotal); result.put("depositTotal", depositTotal);
result.put("withdrawalTotal", withdrawalTotal); result.put("withdrawalTotal", withdrawalTotal);
result.put("netTotal", depositTotal.subtract(withdrawalTotal)); // Reported separately as well, so a screen can show where the difference comes from rather
// than presenting a deposit total its own rows do not explain.
result.put("ddCashTotal", ddCashTotal);
result.put("ddCashCount", ddCashCount);
result.put("depositTotalWithDd", depositTotal.add(ddCashTotal));
result.put("netTotal", depositTotal.add(ddCashTotal).subtract(withdrawalTotal));
result.put("exchangeRows", exchangeRows); result.put("exchangeRows", exchangeRows);
result.put("cashierId", scopedCashierId);
result.put("scopedToCashier", scopedCashierId != null);
// Opening and closing cash in the drawer.
//
// netTotal above is only the day's counter movement, which is not what the cashier is
// holding. A closing figure has to carry the previous balance forward, so it is derived from
// the cumulative position -- vault allocations, settlements, inter-cashier legs and
// exchanges as well as the counter work -- read once as of the day before and once as of the
// day itself. The gap between the two is the day's entire cash movement, a wider figure than
// deposits minus withdrawals, which is why both are reported rather than one derived from
// the other: when they differ, the difference is vault and inter-cashier traffic.
//
// Only a cashier-scoped call can answer this. A branch-wide opening balance belongs to the
// branch day register, not to this report.
if (scopedCashierId != null) {
final BigDecimal openingBalance = positionTotalAsOf(scopedCashierId,
LocalDate.parse(txnDate).minusDays(1).toString());
final BigDecimal closingBalance = positionTotalAsOf(scopedCashierId, txnDate);
result.put("openingBalance", openingBalance);
result.put("closingBalance", closingBalance);
result.put("cashMovement", closingBalance.subtract(openingBalance));
// The day on its own terms, which is what a cashier actually reconciles.
//
// A cashier does not carry a balance: they draw cash from the vault in the morning, work
// the counter, and hand it back at close. So the cumulative figures above answer a
// question nobody at the counter asks — and one carrying years of history answers it
// with a number that cannot be counted against anything on the desk. These four are the
// day itself: taken from the vault, in over the counter, out over the counter, settled
// back. What is left is what should physically be in the drawer.
result.put("todayVaultIn", dayLegTotal(
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ct.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todaySettledOut", dayLegTotal(
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
+ " AND COALESCE(cs.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierIn", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierOut", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
}
return gson.toJson(result); return gson.toJson(result);
} }
/** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */
private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) {
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate);
return total == null ? BigDecimal.ZERO : total;
}
/**
* What one cashier is holding at the end of a given date, in rupees.
*
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO;
}
@GET @GET
@Path("excess") @Path("excess")
public String excess(@QueryParam("threshold") BigDecimal threshold, public String excess(@QueryParam("threshold") BigDecimal threshold,
@@ -648,7 +1090,17 @@ public class CashReportApiResource {
return gson.toJson(result); return gson.toJson(result);
} }
private Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) { /**
* Denomination-wise net cash a cashier is holding as of a date: everything allocated to them,
* settled or handed on by them, and taken or paid over their counter, netted per note and coin.
*
* <p>
* Public because the settlement register checks against it before accepting a hand-over. It has
* to be this figure and not a second sum of the same eight legs: two implementations of "what
* does this drawer hold" drift, and the one place that matters is the moment somebody is told
* they cannot settle what they do not have.
*/
public Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate // Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count " final String sqlIn1 = "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 " + "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
@@ -672,7 +1124,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) " + "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; + "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault // Settlements back to vault
@@ -698,7 +1150,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) " + "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; + "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction) // Exchange IN: customer gave denomination to cashier (RECEIVED direction)
@@ -887,21 +1339,42 @@ public class CashReportApiResource {
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr"); final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr"); final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
// 2. Paged data query // 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
final String dataSql = "SELECT " // entry ever has a denomination breakdown for -- so on its own it names only the cash side
// (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the
// entry actually moved money between. Every voucher raised through VoucherService (both this
// one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line,
// the same table the My Work register reads its own debit/credit columns from -- joined here
// by voucher_no so this report shows the identical pair rather than only the half this
// table's own denomination row happens to know about.
final String baseSql = "SELECT "
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), " + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " std.account_no AS ac_no, " + " std.account_no AS ac_no, "
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, " + " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
+ " MAX(std.txn_date) AS txn_date, " + " MAX(std.txn_date) AS txn_date, "
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, " + " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
+ " MAX(std.created_at) AS sort_key, "
+ " MAX(std.officer_username) AS teller_name, " + " MAX(std.officer_username) AS teller_name, "
+ " MAX(std.payment_type) AS payment_type, " + " MAX(std.payment_type) AS payment_type, "
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, " + " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, " + " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount " + " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
+ baseGroupSql + baseGroupSql;
+ "ORDER BY MAX(std.created_at) "
final String dataSql = "SELECT b.*, "
+ " dr.party_name AS debit_party, dr.account_no AS debit_account, "
+ " cr.party_name AS credit_party, cr.account_no AS credit_account "
+ "FROM (" + baseSql + ") b "
+ "LEFT JOIN LATERAL ("
+ " SELECT party_name, account_no FROM m_voucher_line"
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'DR'"
+ " ORDER BY line_no LIMIT 1) dr ON TRUE "
+ "LEFT JOIN LATERAL ("
+ " SELECT party_name, account_no FROM m_voucher_line"
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'CR'"
+ " ORDER BY line_no LIMIT 1) cr ON TRUE "
+ "ORDER BY b.sort_key "
+ "LIMIT ? OFFSET ?"; + "LIMIT ? OFFSET ?";
// Append limit and offset to params // Append limit and offset to params
@@ -922,6 +1395,12 @@ public class CashReportApiResource {
row.put("txnSource", rs.getString("txn_source")); row.put("txnSource", rs.getString("txn_source"));
row.put("dr", rs.getBigDecimal("dr_amount")); row.put("dr", rs.getBigDecimal("dr_amount"));
row.put("cr", rs.getBigDecimal("cr_amount")); row.put("cr", rs.getBigDecimal("cr_amount"));
// Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher
// was raised before m_voucher_line existed, or belongs to no register that writes one.
row.put("debitParty", rs.getString("debit_party"));
row.put("debitAccount", rs.getString("debit_account"));
row.put("creditParty", rs.getString("credit_party"));
row.put("creditAccount", rs.getString("credit_account"));
return row; return row;
}, pagedParams); }, pagedParams);
@@ -89,14 +89,26 @@ public class VaultPositionService {
""", officeId, dateFilter, tellerId); """, officeId, dateFilter, tellerId);
String anchorLeg = "OPENING_VAULT"; String anchorLeg = "OPENING_VAULT";
if (anchorRows.isEmpty()) { if (anchorRows.isEmpty()) {
anchorLeg = "OPENING"; // The branch-total OPENING leg mixes the vault with whatever every cashier under it
anchorRows = jdbcTemplate.queryForList(""" // holds, so it is only a defensible stand-in for a vault when the office has exactly one
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r // teller. With two, EVERY teller without a vault-scoped row of its own would anchor on
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) // the same office-wide breakdown, and the frontend's Branch Total -- which sums each
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d // teller's vault -- would report roughly twice the branch's actual cash. Worse, a close
WHERE d.register_id = r.id AND d.leg = 'OPENING') // then snapshots that inflated figure as this teller's CLOSING_VAULT and the next open
ORDER BY r.business_date DESC LIMIT 1 // carries it forward, making the phantom permanent. A second teller with no history of
""", officeId, dateFilter); // its own genuinely holds nothing, and returning no breakdown says so honestly.
final Integer tellerCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?",
Integer.class, officeId);
if (tellerCount != null && tellerCount == 1) {
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()) { if (anchorRows.isEmpty()) {
@@ -22,7 +22,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -31,6 +33,8 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository; import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository; import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService; import org.apache.fineract.portfolio.teller.service.GlDispatchService;
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.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
@@ -41,6 +45,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashsettlement") @Path("/v1/cashsettlement")
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
public class CashSettlementApiResource { public class CashSettlementApiResource {
@@ -52,9 +57,22 @@ public class CashSettlementApiResource {
private final PlatformTransactionManager transactionManager; private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper; private final CashierRoleHelper cashierRoleHelper;
private final VoucherService voucherService;
// The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report
// says they are not holding.
private final CashReportApiResource cashReportApiResource;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
// Transactional so the reconciliation precondition and the duplicate-settlement check below are
// read in the same transaction that writes the settlement. They used to run in autocommit while
// the insert happened later inside its own TransactionTemplate, leaving a window where a
// double-clicked Submit passed both checks twice and wrote two settlements for the same
// cashier/date -- each subtracted cumulatively, so the drawer read permanently negative by the
// settlement amount. Migration 0540 adds the unique index that makes the race impossible rather
// than merely unlikely; this annotation is what stops the two halves of the check-then-act from
// being separated by another request's commit.
@POST @POST
@Transactional
public String create(String body) { public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT"); context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
@@ -107,6 +125,8 @@ public class CashSettlementApiResource {
"At least one denomination with a positive count is required."); "At least one denomination with a positive count is required.");
} }
validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate);
final BigDecimal total = sumTotal(denominations); final BigDecimal total = sumTotal(denominations);
CashSettlement header = new CashSettlement(); CashSettlement header = new CashSettlement();
@@ -285,6 +305,67 @@ public class CashSettlementApiResource {
return total; return total;
} }
/**
* Refuses a hand-over of notes the cashier is not holding.
*
* <p>
* Nothing stopped this before, and the damage is not a rejected screen but a drawer that reads
* negative for the rest of its life: a settlement is subtracted from the cashier's position
* cumulatively, so one settlement of cash they never had leaves every later position short by
* that amount, on every date, with no transaction to point at. This database carries exactly
* that — ₹10.5 lakh settled against ₹26 thousand ever allocated, and a Cashier Position screen
* showing counts like -1814 as a result.
*
* <p>
* Checked per denomination, not on the total, for the same reason the vault side is: a drawer
* holding ₹600 in one ₹500 and one ₹100 cannot hand over six ₹100 notes, though the total
* agrees. The holding comes from {@code buildPositionForCashier} — the figure the Cashier
* Position screen shows — so what a cashier is told they hold is what they are allowed to settle.
*
* <p>
* A cashier with no position at all is left alone: a first settlement on a drawer whose
* allocations were recorded without a breakdown has nothing to check against, and blocking it
* would stop a branch settling at all rather than prevent a mistake.
*/
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
final LocalDate settlementDate) {
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
settlementDate.toString());
@SuppressWarnings("unchecked")
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows == null || heldRows.isEmpty()) {
return;
}
final Map<Long, Long> held = new java.util.HashMap<>();
for (final Map<String, Object> row : heldRows) {
final Object id = row.get("denominationId");
final Object count = row.get("count");
if (id instanceof Number && count instanceof Number) {
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
}
}
for (final JsonElement el : denominations) {
final JsonObject item = el.getAsJsonObject();
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 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 long available = held.getOrDefault(denominationId, 0L);
if (count > available) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.insufficient.cashier.denomination",
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
+ ", settling " + count + ". Check the Cashier Position report before settling.");
}
}
}
private boolean hasPositiveCount(JsonArray items) { private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) { for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true; if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.teller.service.GlDispatchService; import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer; import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
@@ -52,6 +53,9 @@ public class CashTransferApiResource {
private final PlatformTransactionManager transactionManager; private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper; private final CashierRoleHelper cashierRoleHelper;
// The same source the Cash Transfer screen reads its Available column from, so what is offered
// and what is checked cannot disagree.
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
@POST @POST
@@ -327,46 +331,39 @@ public class CashTransferApiResource {
* The total-amount check above can pass while a specific note/coin still runs short — a vault * 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 * 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 * 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 * vault actually holds of it.
* 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 * <p>
* denomination against, so this is skipped rather than blocking every transfer on a day that * The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen
* simply never captured one. * shows in its Available column. It has to: this check used to walk the register itself and read
* the branch-total {@code OPENING} leg, which mixes the vault with whatever every cashier under
* it is holding and is anchored on a different day. The screen offered a ₹100 note the vault
* plainly had and the transfer was then refused as "vault has 0" — two answers to one question,
* from two sources. Reading the service means the number that was offered is the number that is
* checked, and neither can drift from the other again.
*
* <p>
* A day with no breakdown recorded anywhere 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, private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
final JsonArray denominations, final java.sql.Date businessDate) { final JsonArray denominations, final java.sql.Date businessDate) {
final List<Object[]> openingRows = jdbcTemplate.query(""" final Map<String, Object> position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString());
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
WHERE register_id = ? AND leg = 'OPENING'
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
if (openingRows.isEmpty()) {
return; return;
} }
final Map<Long, Integer> available = new java.util.HashMap<>(); final Map<Long, Integer> available = new java.util.HashMap<>();
for (final Object[] row : openingRows) { @SuppressWarnings("unchecked")
available.merge((Long) row[0], (Integer) row[2], Integer::sum); final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
} if (heldRows != null) {
for (final Map<String, Object> row : heldRows) {
final List<Object[]> settledRows = jdbcTemplate.query(""" final Object id = row.get("denominationId");
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd final Object count = row.get("count");
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id if (id instanceof Number && count instanceof Number) {
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum);
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) { for (final JsonElement el : denominations) {
@@ -1,6 +1,8 @@
package org.apache.fineract.portfolio.cashtransfer.api; package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
@@ -21,6 +23,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -239,7 +242,15 @@ public class ShareAdvanceCollectionApiResource {
// account to look in is never guessed from product configuration — the counter selects it // 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 // 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. // wrong account and a plain voucher lookup should never depend on that being right.
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
if (adopted == null) {
// A member can also fund the same GL account through the Clerk's Disbursement Request
// register (a COLLECTION request debits the member's own savings account and credits a
// GL account, approved the same way a withdrawal is) — a second, equally legitimate way
// for money to reach Share Sundry / a fee-income account without a cashier counter
// deposit ever being raised.
adopted = adoptApprovedClerkDisbursementCollection(voucherNo, glAccountNoOverride);
}
if (adopted != null) { if (adopted != null) {
return Response.ok(gson.toJson(adopted)).build(); return Response.ok(gson.toJson(adopted)).build();
} }
@@ -637,6 +648,81 @@ public class ShareAdvanceCollectionApiResource {
receiptNo); receiptNo);
} }
/**
* Adopts an approved Clerk Disbursement Request ("COLLECTION" direction) into the Share Sundry GL as a share
* advance collection.
*
* A COLLECTION request debits the member's own savings account and credits a GL account, approved by a manager
* the same way a clerk withdrawal is -- a second, equally legitimate way for a member's money to reach Share
* Sundry (or a fee-income account) besides a cashier counter deposit. The request itself carries no client id
* column; the member is only recorded, as JSON, on the debit leg of {@code credit_entries}.
*
* @param glAccountNoOverride
* required -- same contract as {@link #adoptApprovedDeposit}: the GL account the counter selected,
* never guessed from product configuration.
* @return the newly created collection row, or null when no approved COLLECTION request matches.
*/
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
final String glAccountNo = glAccountNoOverride.trim();
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request"
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
+ " ORDER BY id DESC",
voucherNo, glAccountNo);
if (requests.isEmpty()) {
return null;
}
final Map<String, Object> req = requests.get(0);
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
"Voucher " + voucherNo + " does not debit a member's savings account, so it cannot be matched to a member.");
}
final Long clientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(clientId);
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
final Object actionedAt = req.get("actioned_at");
final Object submittedAt = req.get("submitted_at");
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String 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), req.get("dr_amount"), currency, voucherNo,
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement 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);
}
/** The savings account id named on the debit ("Dr") leg of a Clerk Disbursement Request's {@code credit_entries}
* JSON array, or null when that leg names a GL account instead (no member to match against). */
private Long debitSavingsAccountId(final String creditEntriesJson) {
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
return null;
}
final JsonArray entries = JsonParser.parseString(creditEntriesJson).getAsJsonArray();
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final boolean isDebit = entry.has("dbCr") && "Dr".equalsIgnoreCase(entry.get("dbCr").getAsString());
if (isDebit && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
return entry.get("savingsAccountId").getAsLong();
}
}
return null;
}
private Long loadClientOfficeId(final Long clientId) { private Long loadClientOfficeId(final Long clientId) {
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId); return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
} }
@@ -751,6 +837,7 @@ public class ShareAdvanceCollectionApiResource {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, 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 LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, " + " entry_date, type_enum, amount, description, entity_id, "
@@ -21,6 +21,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
@@ -360,6 +361,7 @@ public class ShareCollectionApiResource {
final LocalDate entryDate, final Long shareAccountId, final LocalDate entryDate, final Long shareAccountId,
final long userId, final LocalDateTime now, final String currencyCode, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " "INSERT INTO acc_gl_journal_entry "
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
@@ -67,6 +67,7 @@ import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProces
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -82,6 +83,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
public class ClerkDisbursementRequestApiResource { public class ClerkDisbursementRequestApiResource {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper; private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -143,6 +145,14 @@ public class ClerkDisbursementRequestApiResource {
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) { if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
throw apiError(400, "Each entry must have an account number"); throw apiError(400, "Each entry must have an account number");
} }
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
// working, since nothing about a restricted customer should prevent them being paid.
// A GL leg carries no savingsAccountId and the guard ignores it.
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
}
if ("Dr".equals(dbCr)) { if ("Dr".equals(dbCr)) {
totalDr = totalDr.add(entryAmt); totalDr = totalDr.add(entryAmt);
} else { } else {
@@ -187,12 +197,26 @@ public class ClerkDisbursementRequestApiResource {
activityLog, Timestamp.from(Instant.now()), requestType activityLog, Timestamp.from(Instant.now()), requestType
); );
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a // A collection pulls money into the GL, so it reads as a credit; a disbursement moves
// bank transfer both move money out of the source side, so they read as a debit. // money out of the source side, so it reads as a debit — but only when the TXN TP. the
final String voucherPrefix = "COLLECTION".equals(requestType) // clerk actually picked is Cash. That dropdown is the real-world signal for whether notes
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT; // cross the counter, the same way it already governs every teller screen in this system
// (isCashPayment): a clerk who selects Transfer, Cheque, DD or NEFT/RTGS is saying no cash
// moves for this request, whatever GL it happens to post against, so it belongs in the
// Journal series instead of being counted as real cash received or paid on My Vouchers.
final boolean isCashPayment = paymentTypeId != null && Boolean.TRUE.equals(jdbcTemplate.queryForObject(
"SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId));
final String voucherPrefix = !isCashPayment ? VoucherService.PREFIX_JOURNAL
: "COLLECTION".equals(requestType) ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
// The voucher's own TYPE column used to be hard-coded to VoucherService.TYPE_DISBURSEMENT
// for every request this register raises, so My Vouchers showed "DISBURSEMENT" even for a
// Collection or a Fund Transfer the clerk had explicitly picked a different TYPE for on the
// entry screen — the one place this register's actual four-way distinction ought to have
// been visible was throwing it away. Stored as the clerk's own requestType instead, in the
// same words the TYPE dropdown already uses.
final String voucherTypeLabel = voucherTypeLabelFor(requestType);
final String voucherNo = voucherService.createVoucher( final String voucherNo = voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST, voucherTypeLabel, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, txnAmount, currentUser, voucherPrefix); newId, txnAmount, currentUser, voucherPrefix);
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId); "UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
@@ -1010,13 +1034,26 @@ public class ClerkDisbursementRequestApiResource {
*/ */
/** /**
* Bank-transfer settlement voucher, drawn from the same central register as every other * 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 * voucher so the two numbering schemes cannot drift apart.
* the debited accounts, so it reads as a debit: DR/2108/0001. *
* Numbered Journal rather than Debit: both sides of a bank transfer are member accounts, and
* the money never touches Cash in Hand or the vault either way (see the "no CashVault entry
* created anywhere" comments where these accounts are debited/credited directly). A DR number
* would have read as real cash paid out, double-counting the figure in My Vouchers on top of
* the day's actual Payment total.
*/ */
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) { private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
return voucherService.createVoucher( return voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG, "Bank Transfer", VoucherService.REF_BANK_TRANSFER_LOG,
logId, amount, currentUser, VoucherService.PREFIX_DEBIT); logId, amount, currentUser, VoucherService.PREFIX_JOURNAL);
}
/** The TYPE column's label, in the same words the clerk's own TYPE dropdown uses for it. */
private static String voucherTypeLabelFor(final String requestType) {
if ("COLLECTION".equals(requestType)) return "Collection";
if ("BANK_TRANSFER".equals(requestType)) return "Bank Transfer";
if ("FUND_TRANSFER".equals(requestType)) return "Fund Transfer";
return "Disbursement";
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1250,7 +1287,13 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString()) .withJson(payload.toString())
.build(); .build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd); // This disbursement/collection request already raised its own voucher at submission and
// attaches every leg to it in addDisbursementVoucherLines() after approval succeeds — without
// withOwnVoucher() the plain savings withdrawal command below raises a second, orphaned
// voucher of its own for the same money (see VoucherService.withOwnVoucher's javadoc), which
// is what was double-counting a Collection's amount in both Received and Payments on My Vouchers.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
return result.getResourceId() != null ? result.getResourceId().toString() : null; return result.getResourceId() != null ? result.getResourceId().toString() : null;
} }
@@ -49,6 +49,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -76,10 +77,13 @@ import org.springframework.transaction.annotation.Transactional;
* Close Savings Account screen), and a cashier hands over the physical cash against the token, * Close Savings Account screen), and a cashier hands over the physical cash against the token,
* recording the denomination breakdown. * recording the denomination breakdown.
* *
* Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded to the * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded DOWN
* nearest rupee for the physical hand-over; any few-paise difference is absorbed by the dedicated * to the whole rupee for the physical hand-over -- 9,883.79 is paid as 9,883 -- and the dropped paise
* "Round Off Account" GL account via one small adjusting journal entry, never touching the member's own * are absorbed by the Rounding Adjustment account the savings product names, via one small adjusting
* ledger (which stays exactly correct to the paisa). * journal entry. The member's own ledger stays exactly correct to the paisa.
*
* Down rather than to the nearest rupee, so the branch never pays out more cash than the account
* actually held: rounding 9,883.79 up to 9,884 hands over 21 paise the member never had.
*/ */
@Path("/v1/clerk/closure-requests") @Path("/v1/clerk/closure-requests")
@Component @Component
@@ -91,6 +95,13 @@ public class ClerkClosureRequestApiResource {
// and a field of the same name reads as that variable at a glance. // and a field of the same name reads as that variable at a glance.
private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class); private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class);
/** acc_product_mapping.product_type for a savings product. */
private static final int PRODUCT_TYPE_SAVING = 2;
/** {@code CashAccountsForSavings.ROUNDING_ADJUSTMENT} */
private static final int ROUNDING_ADJUSTMENT_ACCOUNT_TYPE = 26;
/** Where the paise go on a product that has no mapping of its own yet. */
private static final String DEFAULT_ROUND_OFF_GL_CODE = "501200";
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper; private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
@@ -170,7 +181,7 @@ public class ClerkClosureRequestApiResource {
final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate); final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate);
// Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING, // Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING,
// exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure. // exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure.
final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR);
final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount); final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -215,6 +226,31 @@ public class ClerkClosureRequestApiResource {
private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) { private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) {
final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false); final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
savingsAccountWritePlatformService.postInterest(account, true, closureDate, false); savingsAccountWritePlatformService.postInterest(account, true, closureDate, false);
// What the account itself says is still owed once that posting has run: everything the
// account has earned over its life, less everything already credited to the member. The
// account's own summary is the figure the account screen shows, so a closure and that screen
// can no longer disagree about what is outstanding.
//
// Normally this is zero -- the posting above has just credited the shortfall, which is the
// whole point of running it. It is not zero when the summary carries interest the posting did
// not reach: interest accrued against a period the posting run does not close, most often on
// a cycle that has not yet ended. Left alone that interest is simply dropped, because the
// account is about to be closed and no later run will ever pay it.
//
// Only ever pays out what the two totals still differ by, so interest already credited cannot
// be paid a second time, and a negative difference (more posted than earned, which a reversal
// can leave behind) is ignored rather than taken back off the member here.
final BigDecimal earned = zeroIfNull(account.getSummary().getTotalInterestEarned());
final BigDecimal posted = zeroIfNull(account.getSummary().getTotalInterestPosted());
final BigDecimal stillOwed = earned.subtract(posted);
if (stillOwed.compareTo(BigDecimal.ZERO) > 0) {
LOG.warn("Closure of savings account {}: {} of accrued interest is still unposted after posting up to {} "
+ "(earned {} against posted {}). The payout below does not include it.", savingsAccountId, stillOwed,
closureDate, earned, posted);
}
return zeroIfNull(account.getSummary().getAccountBalance()); return zeroIfNull(account.getSummary().getAccountBalance());
} }
@@ -291,7 +327,7 @@ public class ClerkClosureRequestApiResource {
res.addProperty("total", total); res.addProperty("total", total);
// What a cashier could physically count out: no paisa coins circulate, so the hand-over is // What a cashier could physically count out: no paisa coins circulate, so the hand-over is
// always whole rupees (the difference lands on the Round Off Account at approval). // always whole rupees (the difference lands on the Round Off Account at approval).
res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); res.addProperty("roundedTotal", total.setScale(0, RoundingMode.FLOOR));
res.addProperty("interestUpToDate", true); res.addProperty("interestUpToDate", true);
res.addProperty("asOfDate", closureDate.toString()); res.addProperty("asOfDate", closureDate.toString());
return gson.toJson(res); return gson.toJson(res);
@@ -509,12 +545,12 @@ public class ClerkClosureRequestApiResource {
if (txn != null) { if (txn != null) {
txnId = txn.get("id").toString(); txnId = txn.get("id").toString();
closingAmount = new BigDecimal(txn.get("amount").toString()); closingAmount = new BigDecimal(txn.get("amount").toString());
cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR);
roundingAmount = cashierDisbursedAmount.subtract(closingAmount); roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) { if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) {
final Long officeId = ((Number) txn.get("office_id")).longValue(); final Long officeId = ((Number) txn.get("office_id")).longValue();
postRoundingAdjustment(officeId, roundingAmount, currentUser); postRoundingAdjustment(officeId, roundingAmount, currentUser, savingsAccountId);
} }
} }
@@ -770,11 +806,11 @@ public class ClerkClosureRequestApiResource {
lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount)); lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO); final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
if (roundingDirection > 0) { if (roundingDirection > 0) {
lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
} }
lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount)); lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
if (roundingDirection < 0) { if (roundingDirection < 0) {
lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
} }
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
@@ -799,9 +835,10 @@ public class ClerkClosureRequestApiResource {
* extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the * extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the
* difference as an expense). Rounded down, both legs flip. * difference as an expense). Rounded down, both legs flip.
*/ */
private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser) { private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser,
final Long savingsAccountId) {
final Map<String, Object> cashGl = resolveCashGlAccount(); final Map<String, Object> cashGl = resolveCashGlAccount();
final Long roundOffGlId = resolveRoundOffGlAccount(); final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
final String currencyCode = jdbcTemplate.queryForObject( final String currencyCode = jdbcTemplate.queryForObject(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
final BigDecimal amount = roundingAmount.abs(); final BigDecimal amount = roundingAmount.abs();
@@ -814,10 +851,15 @@ public class ClerkClosureRequestApiResource {
// type_enum: 2 = DEBIT, 1 = CREDIT. // type_enum: 2 = DEBIT, 1 = CREDIT.
if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) { if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) {
// Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash // Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash
//
// Unreachable while the hand-over rounds down, and kept so: an amount already whole to
// the rupee produces no adjustment at all, and if the rounding rule is ever changed
// back this leg is still correct rather than silently posting the wrong way.
insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser);
insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser);
} else { } else {
// Rounded down: less cash left the drawer than the member's ledger gave up. // Rounded down, which is now every case: the member's ledger gave up the full paisa
// amount but less cash left the drawer, so the paise stay with the bank.
// Dr Cash / Cr Round Off // Dr Cash / Cr Round Off
insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser);
insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser);
@@ -837,11 +879,39 @@ public class ClerkClosureRequestApiResource {
return rows.get(0); return rows.get(0);
} }
private Long resolveRoundOffGlAccount() { /**
* The GL the dropped paise are absorbed by, from the closing account's own savings product.
*
* <p>
* Read from the product's Accounting tab like every other GL this closure touches, so a bank
* on its own chart of accounts posts to the head it chose. This used to be
* {@code gl_code = '501200'} written into the query, which meant a tenant whose chart has no
* such code could not close an account at all, and a bank that keeps its round-off elsewhere
* had to create a 501200 anyway.
*
* <p>
* Falls back to that code where the product has no mapping yet — every product created before
* the row existed is in that position, and a closure must not fail over a few paise.
*/
private Long resolveRoundOffGlAccount(final Long savingsAccountId) {
if (savingsAccountId != null) {
final List<Long> mapped = jdbcTemplate.queryForList("""
SELECT m.gl_account_id
FROM acc_product_mapping m
JOIN m_savings_account sa ON sa.product_id = m.product_id
WHERE sa.id = ? AND m.product_type = ? AND m.financial_account_type = ?
LIMIT 1
""", Long.class, savingsAccountId, PRODUCT_TYPE_SAVING, ROUNDING_ADJUSTMENT_ACCOUNT_TYPE);
if (!mapped.isEmpty()) {
return mapped.get(0);
}
}
final List<Long> ids = jdbcTemplate.queryForList( final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_account WHERE gl_code = '501200' LIMIT 1", Long.class); "SELECT id FROM acc_gl_account WHERE gl_code = ? LIMIT 1", Long.class, DEFAULT_ROUND_OFF_GL_CODE);
if (ids.isEmpty()) { if (ids.isEmpty()) {
throw apiError(500, "Round Off Account (gl_code 501200) is not configured for this tenant yet."); throw apiError(500, "No Rounding Adjustment account is mapped on the savings product, and the default "
+ "Round Off Account (gl_code " + DEFAULT_ROUND_OFF_GL_CODE + ") does not exist in this tenant. "
+ "Map one on the product's Accounting tab.");
} }
return ids.get(0); return ids.get(0);
} }
@@ -850,6 +920,7 @@ public class ClerkClosureRequestApiResource {
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
final AppUser currentUser) { final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
@@ -49,6 +49,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -56,7 +57,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; 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.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -77,10 +82,13 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ClerkShareWithdrawalRequestApiResource { public class ClerkShareWithdrawalRequestApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper; private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date, // Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent // meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's // format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
@@ -701,9 +709,17 @@ public class ClerkShareWithdrawalRequestApiResource {
// shares left for the native close command to force-redeem without oversight. // shares left for the native close command to force-redeem without oversight.
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString()); final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
// redemption settled into a member's savings account left nothing on the voucher register to
// read it back from. The GL entries were always written — it is the slip that was missing.
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
final JsonObject res = new JsonObject(); final JsonObject res = new JsonObject();
res.addProperty("resourceId", id); res.addProperty("resourceId", id);
res.addProperty("status", "APPROVED"); res.addProperty("status", "APPROVED");
if (voucherNo != null) {
res.addProperty("voucherNo", voucherNo);
}
if (shareTxnId != null) { if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId); res.addProperty("shareTransactionId", shareTxnId);
} }
@@ -742,6 +758,62 @@ public class ClerkShareWithdrawalRequestApiResource {
return true; return true;
} }
/**
* The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings.
*
* <p>
* Mirrors the GL pair {@link #creditLinkedSavingsAccount} posts, so the slip and the ledger say
* the same thing. A redemption with no linked savings account settles nothing yet and raises no
* voucher — there is no second leg to name until somebody settles it by hand.
*
* <p>
* A failure here is logged and swallowed, never rethrown: the redemption, its GL entries and the
* savings credit have all already succeeded in this transaction, and losing the slip is worth
* less than rolling back a member's money.
*/
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map<?, ?> record,
final Settlement settlement, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || settlement == null
|| settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
try {
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
shareAccountId);
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
final Long savingsAccountId = jdbcTemplate
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
.findFirst().orElse(null);
final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
final String savingsAccountNo = savingsAccountId == null ? null
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
.stream().findFirst().orElse(null);
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
// savings account, which is the receipt series — the share account is the source, not a
// counter payment.
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT,
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
if (settlement.savingsTransactionId() != null) {
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
settlement.savingsTransactionId());
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
return null;
}
}
private record Settlement(BigDecimal amount, Long savingsTransactionId) {} private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
/** /**
@@ -990,6 +1062,7 @@ public class ClerkShareWithdrawalRequestApiResource {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, 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 LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, " + " entry_date, type_enum, amount, description, entity_id, "
@@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -77,6 +79,7 @@ public class ClerkWithdrawalRequestApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class); private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper; private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -121,6 +124,7 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required"); throw apiError(400, "Token number (TOK. NO.) is required");
} }
validateTokenUniqueToday(tokNo, null); validateTokenUniqueToday(tokNo, null);
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -453,6 +457,11 @@ public class ClerkWithdrawalRequestApiResource {
final Long savingsAccountId = record.get("savings_account_id") != null final Long savingsAccountId = record.get("savings_account_id") != null
? ((Number) record.get("savings_account_id")).longValue() : null; ? ((Number) record.get("savings_account_id")).longValue() : null;
// Checked again on approval, not only on submission: a request can sit in the queue for days,
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
// below refuses a dormant account anyway, but that failure would surface to the manager as a
// rejected transaction after the approval was recorded; this says why, before anything moves.
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id"); final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date"); final Object txnDateObj = record.get("txn_date");
@@ -788,7 +797,13 @@ public class ClerkWithdrawalRequestApiResource {
.withJson(payload.toString()) .withJson(payload.toString())
.build(); .build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); // The withdrawal register raised its own voucher at submission and attaches the member and
// till sides to it on approval, so the savings command must not raise a second one over the
// same money -- one withdrawal was appearing twice on the cashier's payment report, once
// under the register's number and once under the savings transaction's, doubling the day's
// cash paid out.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null; return result.getResourceId() != null ? result.getResourceId().toString() : null;
} }
@@ -816,7 +831,11 @@ public class ClerkWithdrawalRequestApiResource {
.withJson(payload.toString()) .withJson(payload.toString())
.build(); .build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); // Suppressed for the same reason as the savings path above. Current accounts raise no
// voucher of their own today, so this changes nothing now; it is here so that the day one
// is added, this register does not silently start double-counting again.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null; return result.getResourceId() != null ? result.getResourceId().toString() : null;
} }
@@ -0,0 +1,404 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredentialService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
/**
* A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share
* accounts are out of scope for this endpoint, see below), looked up by client number OR mobile number
* (the path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no})
* rather than the internal client id -- these are what a counter clerk has on the slip, or what a
* third-party caller was given, respectively, so no separate client-search step is needed before this
* call. Built so the whole thing, including the client lookup itself, resolves in a small handful of
* index-scan-only queries.
*
* Deliberately narrow on purpose:
* <ul>
* <li>Only ACTIVE accounts (status_enum 300) are returned -- a closed, pending, or rejected account is
* not part of "what does this client currently hold", which is the question this endpoint answers.</li>
* <li>No balance/currentValue/onHoldFunds figures at all, for any account type -- this is an account
* listing, not a balance enquiry; a caller that needs the money figure is expected to hit the
* account-specific balance endpoint for it (kept as a smaller, separate, cacheable concern rather than
* mixed into every listing response).</li>
* <li>Share accounts are excluded entirely -- they carry no balance in the deposit sense (approved
* shares x unit price is a valuation, not a balance) and don't belong in a "deposit accounts" listing.</li>
* </ul>
*
* Branch details (name, code, address, pincode, IFSC, MICR -- see {@code m_office_profile}, migrations
* 0511-0513/0538) and bank details (name, SWIFT/BIC, registered address, contact -- see
* {@code m_institution_profile}) are included on every response: a caller resolving "which branch/bank
* is this client's account actually at" (for a passbook header, a cheque book request, a KYC document)
* would otherwise need two more round trips, to {@code /v1/branchprofile/{officeId}} and
* {@code /v1/institutionprofile}.
*
* Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData}
* (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to
* m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
* caller doesn't need. Fewer joins and fewer columns is what actually makes this fast, not a different
* query engine.
*
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as
* they do every other endpoint; a third-party integration that should never see a Fineract username or
* password sends an {@code X-Api-Key} header instead (see {@link ApiClientCredentialService}), minted
* via {@code POST /v1/apiclients} by a super user. This path is carved out as {@code permitAll()} in
* {@code SecurityConfig} for exactly that second case -- the same posture this fork already uses for the
* IMPS and NPCI receivers, where Spring Security's outer gate stays open for one narrow path and the
* handler itself is what actually authenticates the caller.
*/
@Path("/v1/clientaccounts")
@Component
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of a client's ACTIVE Savings/Fixed Deposit/Recurring Deposit/Current accounts (no balances, no Share accounts) plus their branch details, by client number or mobile number.")
@RequiredArgsConstructor
public class ClientAllAccountsApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ApiClientCredentialService apiClientCredentialService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// Same header-precedence list CallerIpTrackingFilter uses elsewhere in this fork, kept local
// rather than depending on that filter bean: it's only conditionally registered
// (fineract.ip-tracking.enabled), and an API-key IP allowlist has to work regardless of whether
// that unrelated feature toggle is on.
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
// ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses
// the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the
// same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD
// (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table --
// current accounts were never folded into m_savings_account here). Every leg filters on
// client_id, which is indexed on both tables (fksa00000000000001, idx_ca_client_id) -- an
// index-only lookup per leg, not a scan.
private static final String ACCOUNTS_SQL = """
SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id,
sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
p.name AS product_name, p.short_name AS short_product_name,
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
FROM m_savings_account sa
JOIN m_savings_product p ON p.id = sa.product_id
WHERE sa.client_id = ? AND sa.status_enum = 300
UNION ALL
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id,
ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
ca.submittedon_date, ca.activatedon_date
FROM m_current_account ca
JOIN m_current_product cp ON cp.id = ca.product_id
WHERE ca.client_id = ? AND ca.status_enum = 300
""";
// Branch details for the client's own office -- name, code, address, pincode, IFSC, MICR -- so a
// caller doesn't need a second round trip to /v1/branchprofile/{officeId} just to print a passbook
// header or route a cheque book request. LEFT JOIN: an office with no m_office_profile row yet
// (see BranchProfileApiResource) is a valid, ordinary state, not an error -- the profile fields
// just come back null.
private static final String BRANCH_SQL = """
SELECT o.id AS office_id, o.name AS office_name, p.branch_code, p.branch_type, p.address,
p.pincode, p.ifsc_code, p.micr_code, p.contact_phone, p.contact_email
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
WHERE o.id = ?
""";
// The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row,
// id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a
// payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's
// IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative
// block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and
// the created/updated audit columns, which are an internal concern of the Bank Profile screen, not
// this endpoint's caller.
private static final String BANK_SQL = """
SELECT bank_name, legal_name, short_name, bank_code, swift_bic,
address_line1, address_line2, city, district, state, pincode,
contact_phone, contact_email, customer_care_number, website
FROM m_institution_profile WHERE id = 1
""";
@GET
@Path("/{identifier}")
@Produces(MediaType.APPLICATION_JSON)
public String retrieveByIdentifier(@PathParam("identifier") final String identifier,
@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
// Dual-mode: a header present means a third-party caller, checked against m_api_client and
// never touching Fineract's own login. No header means an ordinary logged-in user -- exactly
// the same authenticatedUser() gate every other resource in this codebase uses. The two are
// mutually exclusive on purpose: a request carrying an API key is never also allowed to fall
// back to "well, maybe they're logged in too" -- that would let a stolen key ride along on a
// legitimate session's cookies/headers.
if (StringUtils.isNotBlank(apiKey)) {
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
} else {
context.authenticatedUser();
}
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
final Map<String, Object> client = resolveClient(identifier, tenant);
final Long clientId = ((Number) client.get("id")).longValue();
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId);
final Map<String, Integer> countByType = new LinkedHashMap<>();
for (final Map<String, Object> row : rows) {
final String accountType = (String) row.get("accountType");
countByType.merge(accountType, 1, Integer::sum);
}
final Long officeId = ((Number) client.get("office_id")).longValue();
final Map<String, Object> branch = resolveBranch(officeId);
final Map<String, Object> bank = resolveBank();
final JsonObject res = new JsonObject();
// Which bank this response came from -- not decorative. One API key now authenticates
// against every tenant (see ApiClientCredentialService), so the ONLY thing that decides
// whose data comes back is the Fineract-Platform-TenantId header the caller sent. Echoing it
// (plus the bank's display name) back in the body means a caller debugging "why did I get
// client X's data instead of client Y's" can tell from the response alone, without having to
// go back and re-check what header value they actually sent.
res.addProperty("tenantId", tenant.getTenantIdentifier());
res.addProperty("tenantName", tenant.getName());
res.addProperty("searchedIdentifier", identifier);
res.addProperty("clientId", clientId);
res.addProperty("matchedBy", (String) client.get("matched_by"));
res.addProperty("clientNo", (String) client.get("account_no"));
res.addProperty("clientName", (String) client.get("display_name"));
res.addProperty("mobileNo", (String) client.get("mobile_no"));
res.addProperty("officeId", officeId);
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue());
res.add("branch", gson.toJsonTree(branch));
res.add("bank", gson.toJsonTree(bank));
res.addProperty("totalAccounts", rows.size());
res.add("accountsByType", gson.toJsonTree(countByType));
res.add("accounts", gson.toJsonTree(rows));
return gson.toJson(res);
}
/**
* An office with no {@code m_office_profile} row yet (see
* {@code org.apache.fineract.portfolio.orgsetup.api.BranchProfileApiResource}) is a valid, ordinary
* state -- every field beyond {@code officeId}/{@code officeName} just comes back null rather than
* this call failing or being skipped.
*/
private Map<String, Object> resolveBranch(final Long officeId) {
final Map<String, Object> row = jdbcTemplate.queryForMap(BRANCH_SQL, officeId);
final Map<String, Object> branch = new LinkedHashMap<>();
branch.put("officeId", officeId);
branch.put("officeName", row.get("office_name"));
branch.put("branchCode", row.get("branch_code"));
branch.put("branchType", row.get("branch_type"));
branch.put("address", row.get("address"));
branch.put("pincode", row.get("pincode"));
branch.put("ifscCode", row.get("ifsc_code"));
branch.put("micrCode", row.get("micr_code"));
branch.put("contactPhone", row.get("contact_phone"));
branch.put("contactEmail", row.get("contact_email"));
return branch;
}
/**
* The bank running this tenant is a singleton row (id=1) that only gets created the first time
* someone opens the Bank Profile screen (see {@code InstitutionProfileApiResource#ensureRowExists}
* -- this endpoint never creates it itself, it only reads). A tenant where nobody has ever opened
* that screen yet is a valid, ordinary state, not an error -- {@code bank} just comes back {@code
* null} in the response rather than this call failing.
*/
private Map<String, Object> resolveBank() {
final Map<String, Object> row;
try {
row = jdbcTemplate.queryForMap(BANK_SQL);
} catch (final EmptyResultDataAccessException e) {
return null;
}
final Map<String, Object> bank = new LinkedHashMap<>();
bank.put("bankName", row.get("bank_name"));
bank.put("legalName", row.get("legal_name"));
bank.put("shortName", row.get("short_name"));
bank.put("bankCode", row.get("bank_code"));
bank.put("swiftBic", row.get("swift_bic"));
bank.put("addressLine1", row.get("address_line1"));
bank.put("addressLine2", row.get("address_line2"));
bank.put("city", row.get("city"));
bank.put("district", row.get("district"));
bank.put("state", row.get("state"));
bank.put("pincode", row.get("pincode"));
bank.put("contactPhone", row.get("contact_phone"));
bank.put("contactEmail", row.get("contact_email"));
bank.put("customerCareNumber", row.get("customer_care_number"));
bank.put("website", row.get("website"));
return bank;
}
/**
* Resolves the path identifier as a client number first, then a mobile number -- two lookups in
* the worst case, one in the common case, both on unique indexed columns
* ({@code m_client_account_no_key}, {@code m_client_mobile_no_key} / {@code idx_client_mobile_no}),
* so neither costs more than the single index-scan lookup this whole endpoint is built around.
* Client number is tried first because it's the identifier a teller normally has on the slip in
* front of them; falling through to mobile number covers the third-party case where that's the
* only thing the caller was given. A value that happens to collide across both columns for two
* different clients is not handled specially -- account numbers and mobile numbers use disjoint
* formats in this system, so this cannot occur in practice.
*
* {@code tenant} is only for the not-found message: with one API key now valid across every bank
* (see {@link ApiClientCredentialService}), a lookup that finds nothing is as often "right
* identifier, wrong bank" (a stale or mistyped {@code Fineract-Platform-TenantId} header) as it is
* a genuinely unknown client -- naming the bank actually searched turns that from a guessing game
* into something the caller can act on immediately.
*/
private Map<String, Object> resolveClient(final String identifier, final FineractPlatformTenant tenant) {
final List<Map<String, Object>> byAccountNo = jdbcTemplate.queryForList("""
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
FROM m_client WHERE account_no = ?
""", identifier);
if (!byAccountNo.isEmpty()) {
final Map<String, Object> client = new LinkedHashMap<>(byAccountNo.get(0));
client.put("matched_by", "CLIENT_NO");
return client;
}
final List<Map<String, Object>> byMobileNo = jdbcTemplate.queryForList("""
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
FROM m_client WHERE mobile_no = ?
""", identifier);
if (!byMobileNo.isEmpty()) {
final Map<String, Object> client = new LinkedHashMap<>(byMobileNo.get(0));
client.put("matched_by", "MOBILE_NO");
return client;
}
throw apiError(404, "No client found with client number or mobile number '" + identifier + "' in tenant '"
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
}
/**
* Builds the error response body itself rather than throwing a bare {@code jakarta.ws.rs.*}
* exception: {@code NotFoundException(String)} only sets the Java exception's own message (for
* logs), it never puts that text on the wire -- no exception mapper in this app converts a plain
* NotFoundException's message into the response body, so the caller always got
* {@code {timestamp,status,error,path}} with nothing else, no matter how descriptive the message
* passed to the constructor was.
*
* One message field, not the {@code message}/{@code defaultUserMessage}/{@code errors[0]
* .defaultUserMessage} triple {@code ClerkClosureRequestApiResource#apiError} uses: that shape
* exists there to line up with Fineract's own multi-field validation errors, where {@code errors}
* really does carry one entry per invalid parameter. This endpoint never raises more than one
* error at a time, so the same sentence repeated three times said nothing extra, only made the
* body longer.
*/
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("httpStatusCode", httpStatus);
body.addProperty("message", message);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
private String resolveClientIp(final HttpServletRequest request) {
for (final String header : IP_HEADER_CANDIDATES) {
final String value = request.getHeader(header);
if (StringUtils.isNotBlank(value)) {
// X-Forwarded-For can carry a chain ("client, proxy1, proxy2") -- the first entry is
// the original caller, which is what an allowlist entry is written against.
return value.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
/**
* Maps one UNION leg row into a flat, display-ready shape -- category label and human status only.
* No balance figure of any kind: this is an account listing, not a balance enquiry (see the class
* Javadoc).
*/
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
@Override
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final int depositType = rs.getInt("deposit_type");
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("accountType", accountTypeFor(depositType));
row.put("accountId", rs.getLong("account_id"));
row.put("accountNo", rs.getString("account_no"));
row.put("externalId", rs.getString("external_id"));
row.put("productId", rs.getLong("product_id"));
row.put("productName", rs.getString("product_name"));
row.put("shortProductName", rs.getString("short_product_name"));
row.put("currencyCode", rs.getString("currency_code"));
// .toString(), not the raw LocalDate: this Gson instance has no java.time adapter
// registered, so it falls back to reflecting LocalDate's private fields -- which the JVM
// module system blocks outright (java.base does not open java.time to reflection from an
// unnamed module), turning every response into a 500. A plain ISO-8601 string needs no
// adapter and is what every other date field in this API already returns.
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
row.put("status", status);
return row;
}
private String accountTypeFor(final int depositType) {
return switch (depositType) {
case 100 -> "Savings Account";
case 200 -> "Fixed Deposit";
case 300 -> "Recurring Deposit";
case 400 -> "Current Account";
default -> "Other";
};
}
private String toIsoDateString(final LocalDate date) {
return date == null ? null : date.toString();
}
}
}
@@ -70,4 +70,23 @@ public class ClientKycExceptionsApiResource {
} }
return this.toApiJsonSerializer.serialize(exceptions); return this.toApiJsonSerializer.serialize(exceptions);
} }
/**
* Customers whose periodic re-KYC has already fallen due.
*
* <p>
* Sits under the exceptions path because it is the same worklist to the branch — a customer the
* bank has to reach — and the outreach is recorded against the same contact log.
*/
@GET
@Path("lapsed")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve lapsed KYC records", description = "Returns clients whose periodic re-KYC due date has passed, oldest first.")
public String retrieveLapsedKyc() {
this.context.authenticatedUser();
final List<ClientKycData> lapsed = this.readPlatformService.retrieveLapsedKyc();
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
return this.toApiJsonSerializer.serialize(lapsed);
}
} }
@@ -27,6 +27,10 @@ public final class KycRiskRuleData implements Serializable {
private final Long id; private final Long id;
private final String ruleName; private final String ruleName;
/** "KYC" (profile-driven, categorises at onboarding) or "AML" (transaction-monitoring
* scenario). The client risk calculator evaluates KYC rules only - an AML rule describes
* behaviour and may only fire against a posted transaction. */
private final String ruleKind;
private final Integer legalFormId; private final Integer legalFormId;
private final Long occupationCategoryCvId; private final Long occupationCategoryCvId;
private final String occupationCategoryName; private final String occupationCategoryName;
@@ -61,7 +65,7 @@ public final class KycRiskRuleData implements Serializable {
private final BigDecimal txnMinLimitAmount; private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount; private final BigDecimal txnMaxLimitAmount;
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId, public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName, final Long occupationCategoryCvId, final String occupationCategoryName,
final Long occupationCvId, final String occupationName, final Long occupationCvId, final String occupationName,
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome, final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
@@ -75,6 +79,7 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) { final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id; this.id = id;
this.ruleName = ruleName; this.ruleName = ruleName;
this.ruleKind = ruleKind;
this.legalFormId = legalFormId; this.legalFormId = legalFormId;
this.occupationCategoryCvId = occupationCategoryCvId; this.occupationCategoryCvId = occupationCategoryCvId;
this.occupationCategoryName = occupationCategoryName; this.occupationCategoryName = occupationCategoryName;
@@ -111,6 +116,10 @@ public final class KycRiskRuleData implements Serializable {
return ruleName; return ruleName;
} }
public String getRuleKind() {
return ruleKind;
}
public Integer getLegalFormId() { public Integer getLegalFormId() {
return legalFormId; return legalFormId;
} }
@@ -40,6 +40,12 @@ public class KycRiskRuleRequest {
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
private String ruleName; private String ruleName;
@Schema(description = "Which family the rule belongs to: KYC for a profile rule that categorises a customer at "
+ "onboarding from occupation / income / turnover, or AML for a transaction-monitoring scenario that may "
+ "only fire against posted transactions. Omit to have it inferred - supplying transaction settings, or "
+ "naming no profile criteria at all, makes it AML.", example = "KYC")
private String ruleKind;
@Schema(description = "Reason / criteria text explaining why the rule exists", @Schema(description = "Reason / criteria text explaining why the rule exists",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String reason; private String reason;
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions;
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 ApplyKycExpiryRestrictionsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step applyKycExpiryRestrictionsStep(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
return new StepBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
.tasklet(applyKycExpiryRestrictionsTasklet, transactionManager).build();
}
@Bean
public Job applyKycExpiryRestrictionsJob(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
return new JobBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
.start(applyKycExpiryRestrictionsStep(applyKycExpiryRestrictionsTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Nightly pass over customers whose periodic re-KYC has lapsed, applying the restriction each one
* has reached and releasing those whose KYC has since been renewed.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class ApplyKycExpiryRestrictionsTasklet implements Tasklet {
private final KycExpiryRestrictionService kycExpiryRestrictionService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
kycExpiryRestrictionService.applyRestrictions();
return RepeatStatus.FINISHED;
}
}
@@ -35,4 +35,14 @@ public interface ClientKycReadPlatformService {
java.util.List<ClientKycData> retrieveActiveKycExceptions(); java.util.List<ClientKycData> retrieveActiveKycExceptions();
java.util.List<ClientKycData> retrieveResolvedKycExceptions(); java.util.List<ClientKycData> retrieveResolvedKycExceptions();
/**
* KYC records whose periodic re-KYC due date has already passed, oldest first.
*
* <p>
* These are the customers the branch has to chase and, once the grace period runs out, the
* accounts the restriction ladder acts on. A record whose renewal is still ahead is not
* included: nothing is owed on it yet.
*/
java.util.List<ClientKycData> retrieveLapsedKyc();
} }
@@ -25,6 +25,7 @@ import org.apache.fineract.portfolio.client.data.ClientKycData;
import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository; import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKycStatus; import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
import org.apache.fineract.portfolio.client.domain.ClientKycType; import org.apache.fineract.portfolio.client.domain.ClientKycType;
import org.apache.fineract.portfolio.client.domain.KycRiskCategory; import org.apache.fineract.portfolio.client.domain.KycRiskCategory;
@@ -159,4 +160,12 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.map(this::toData) .map(this::toData)
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
} }
@Override
public java.util.List<ClientKycData> retrieveLapsedKyc() {
return this.kycRepository
.findLapsedAsOn(DateUtils.getBusinessLocalDate(), ClientKycStatus.REJECTED.getValue()).stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
} }
@@ -52,6 +52,8 @@ import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTrans
import org.apache.fineract.portfolio.client.kyc.KycVerificationResult; import org.apache.fineract.portfolio.client.kyc.KycVerificationResult;
import org.apache.fineract.portfolio.client.kyc.KycVerificationService; import org.apache.fineract.portfolio.client.kyc.KycVerificationService;
import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer; import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
@@ -73,6 +75,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
private final KycVerificationService kycVerificationService; private final KycVerificationService kycVerificationService;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService; private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
private final SavingsAccountRepository savingsAccountRepository;
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService; private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */ /** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
@@ -414,8 +417,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
getJsonString(panData, "aadhaar_seeding_status"), getJsonString(panData, "aadhaar_seeding_status"),
getJsonString(panData, "category"), getJsonString(panData, "category"),
getJsonString(panData, "status")); getJsonString(panData, "status"));
kyc.tryAutoVerify(); final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue()); LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue());
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
@@ -458,8 +466,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
} }
kyc.markAadhaarVerified(); kyc.markAadhaarVerified();
kyc.tryAutoVerify(); final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("aadhaarVerified", true); changes.put("aadhaarVerified", true);
@@ -500,8 +513,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
} }
kyc.markGstinVerified(); kyc.markGstinVerified();
kyc.tryAutoVerify(); final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("gstinVerified", true); changes.put("gstinVerified", true);
@@ -563,8 +581,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
} }
kyc.markCinVerified(); kyc.markCinVerified();
kyc.tryAutoVerify(); final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("cinVerified", true); changes.put("cinVerified", true);
@@ -633,8 +656,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final boolean companyNameMatched = matchedCompany != null; final boolean companyNameMatched = matchedCompany != null;
kyc.markDinVerified(dinNumber, rawMcaResponse); kyc.markDinVerified(dinNumber, rawMcaResponse);
kyc.tryAutoVerify(); final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("dinNumber", dinNumber); changes.put("dinNumber", dinNumber);
@@ -817,11 +845,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kyc.markVerified(kyc.getCkycNumber()); kyc.markVerified(kyc.getCkycNumber());
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
// A completed re-KYC is what revives the customer's dormant accounts.
//
// RBI's Master Direction allows a dormant account to be operated again only once the
// customer's identity has been established afresh, and this command is that establishment.
// Withdrawals are refused while an account is dormant and no transaction lifts it, so
// without this the account would stay frozen with no way out.
//
// Done here rather than in the nightly expiry sweep on purpose: that sweep visits every
// customer whose KYC is merely current, so reviving there would reopen a dormant account on
// the next run without anyone having verified anything.
final int revived = reviveDormantAccounts(clientId);
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("verifiedOn", kyc.getVerifiedOn()); changes.put("verifiedOn", kyc.getVerifiedOn());
changes.put("expiryDate", kyc.getExpiryDate()); changes.put("expiryDate", kyc.getExpiryDate());
changes.put("kycStatus", kyc.getKycStatusValue()); changes.put("kycStatus", kyc.getKycStatusValue());
changes.put("kycStatusCode", "kyc.status.verified"); changes.put("kycStatusCode", "kyc.status.verified");
if (revived > 0) {
changes.put("dormantAccountsRevived", revived);
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
}
return buildResult(command, client, clientId, kycId, changes); return buildResult(command, client, clientId, kycId, changes);
} }
@@ -1040,6 +1084,39 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
/**
* Clears the dormancy flag from this client's savings accounts, on the strength of a completed
* re-KYC.
*
* <p>
* RBI's Master Direction allows a dormant account to be operated again only once the customer's
* identity has been established afresh, so this is the one thing that lifts it — no transaction
* does, and a withdrawal is refused while the flag stands. Called from every path that
* completes a verification, not only the explicit complete-rekyc command: an officer who
* re-verifies PAN and Aadhaar at the counter has done the same verification and must not have
* to know about a second button.
*
* <p>
* Deliberately not called from the nightly expiry sweep, which visits every customer whose KYC
* is merely current — reviving there would reopen a dormant account on the next run without
* anyone having verified anything.
*
* @return how many accounts were revived
*/
private int reviveDormantAccounts(final Long clientId) {
int revived = 0;
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
if (account.reviveOnReKyc()) {
savingsAccountRepository.save(account);
revived++;
}
}
if (revived > 0) {
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
}
return revived;
}
private CommandProcessingResult buildResult(final JsonCommand command, final Client client, private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
final Long clientId, final Long kycId, final Map<String, Object> changes) { final Long clientId, final Long kycId, final Map<String, Object> changes) {
return new CommandProcessingResultBuilder() return new CommandProcessingResultBuilder()
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
/**
* Applies and lifts the account restrictions that follow a lapsed periodic re-KYC.
*/
public interface KycExpiryRestrictionService {
/**
* Walks every lapsed KYC, applies the stage each one has reached, and releases customers whose
* KYC is current again.
*
* @return what the run did, for the job log
*/
KycExpiryRunSummary applyRestrictions();
/**
* What one run of the ladder did.
*
* @param lapsed
* KYC records past their due date
* @param inGrace
* of those, the ones still inside the grace window with nothing restricted
* @param debitBlocked
* customers whose debits are stopped
* @param fullyBlocked
* customers whose accounts are inoperative
* @param released
* accounts freed because the KYC was renewed
*/
record KycExpiryRunSummary(int lapsed, int inGrace, int debitBlocked, int fullyBlocked, int released) {}
}
@@ -0,0 +1,134 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService.KycExpiryRunSummary;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Applies the restriction ladder that follows a lapsed periodic re-KYC, and lifts it once the KYC
* is renewed.
*
* <p>
* The ladder is described on {@link KycExpiryStage}. In short: nothing happens for the grace period,
* then debits stop, then the account goes inoperative. Closure is never reached automatically.
*
* <p>
* Restrictions are derived from the expiry date on every run rather than remembered, so a KYC that
* is renewed simply stops being lapsed and its accounts are released on the next pass. That also
* makes the job safe to re-run: applying a stage that is already applied changes nothing.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KycExpiryRestrictionServiceImpl implements KycExpiryRestrictionService {
private final ClientKycRepository clientKycRepository;
private final SavingsAccountRepository savingsAccountRepository;
@Override
@Transactional
public KycExpiryRunSummary applyRestrictions() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<ClientKyc> lapsed = clientKycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue());
int debitBlocked = 0;
int fullyBlocked = 0;
int inGrace = 0;
for (final ClientKyc kyc : lapsed) {
final long daysOverdue = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today);
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
if (stage == null || stage.isGrace()) {
// Inside the grace window nothing is restricted; the customer is only being chased.
inGrace++;
continue;
}
final Long clientId = kyc.getClient().getId();
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
boolean changedAny = false;
for (final SavingsAccount account : accounts) {
final boolean changed = stage.isInoperative() ? account.applyKycExpiryFullBlock() : account.applyKycExpiryDebitBlock();
if (changed) {
savingsAccountRepository.save(account);
changedAny = true;
}
}
if (stage.isInoperative()) {
fullyBlocked++;
} else {
debitBlocked++;
}
if (changedAny) {
log.info("Client {} re-KYC lapsed {} days ago — {} applied to their savings accounts", clientId, daysOverdue, stage);
}
}
final int released = releaseRenewed(today);
final KycExpiryRunSummary summary = new KycExpiryRunSummary(lapsed.size(), inGrace, debitBlocked, fullyBlocked, released);
log.info("KYC expiry restrictions applied: {}", summary);
return summary;
}
/**
* Lifts restrictions from customers whose KYC is current again.
*
* <p>
* Renewing a KYC pushes its expiry date into the future, so a renewed customer simply drops out
* of the lapsed set. Nothing records that this job was the one that blocked the account, so only
* the two states the ladder itself sets are cleared — see
* {@link SavingsAccount#releaseKycExpiryBlock()} — and a manual block is left untouched.
*/
private int releaseRenewed(final LocalDate today) {
final List<ClientKyc> current = clientKycRepository.findAllByStatus(ClientKycStatus.VERIFIED.getValue());
int released = 0;
for (final ClientKyc kyc : current) {
if (kyc.getExpiryDate() == null || !kyc.getExpiryDate().isAfter(today)) {
continue;
}
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(kyc.getClient().getId())) {
if (account.releaseKycExpiryBlock()) {
savingsAccountRepository.save(account);
released++;
log.info("Client {} re-KYC renewed to {} — restrictions lifted on savings account {}", kyc.getClient().getId(),
kyc.getExpiryDate(), account.getId());
}
}
}
return released;
}
}
@@ -0,0 +1,201 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* What a customer with a lapsed periodic re-KYC may still do with their savings account.
*
* <p>
* One service because the answer has to be the same at every counter the money can leave by — the
* clerk's withdrawal request, the cashier's deposit, an account-to-account transfer, a transfer to
* or from a GL. Those are separate resources that each validate their own input, and a rule copied
* into four of them is a rule that will disagree with itself the first time one is edited.
*
* <h3>The ladder</h3>
*
* <table>
* <tr><th>Days overdue</th><th>Money out (withdrawal, transfer out)</th><th>Money in (deposit, transfer in)</th></tr>
* <tr><td>0&nbsp;–&nbsp;89 (grace)</td><td>allowed</td><td>allowed</td></tr>
* <tr><td>90&nbsp;–&nbsp;179</td><td>stopped</td><td>allowed</td></tr>
* <tr><td>180 and past</td><td>stopped</td><td>stopped</td></tr>
* </table>
*
* <p>
* Nothing at all is restricted during the grace window, and that is the point of it: RBI's 2021
* amendment is explicit that an account is not to be restricted merely because periodic KYC is
* pending, and that any restriction follows notice and a fair opportunity to comply. The customer
* is being chased, not shut out.
*
* <p>
* The line is drawn by direction, not by the counter the money crosses. Anything taking money out
* is stopped once debits stop — a transfer out is a withdrawal by another name — while anything
* paying money in keeps working, whether it is cash at the counter or a transfer from another
* account. A salary, a benefit, a relative sending funds: those must land, and none of them can
* move the balance anywhere while the debit side is shut.
*
* <h3>Why the days are counted rather than read off the account</h3>
*
* <p>
* The nightly job stamps the restriction on the savings account. Between a customer crossing a
* threshold and that job next running the account still reads as unrestricted, and a request
* raised in that window is accepted — the clerk is told it went through and the customer waits for
* money that was never going to be paid. Counting here closes that window, and matches the job so
* the two can never disagree.
*/
@Service
@RequiredArgsConstructor
public class KycRestrictionGuard {
private final JdbcTemplate jdbcTemplate;
/** What the caller is trying to do, which decides how far down the ladder it survives. */
public enum Operation {
/** Money out over the counter. Stopped once debits stop. */
DEBIT("Withdrawal"),
/** Money in, however it arrives. Allowed until the account is inoperative. */
CREDIT("Deposit"),
/** Money out to another account or a GL — a withdrawal by another name, so stopped with them. */
TRANSFER("Transfer");
private final String label;
Operation(final String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
/**
* Refuses the operation where the customer's re-KYC has lapsed far enough to stop it.
*
* <p>
* Silent for a null account, a client with no KYC record, and one whose KYC has no expiry date
* — none of those is a lapsed re-KYC, and refusing on absent data would block a counter over a
* record that was never set up.
*
* @throws PlatformDataIntegrityException naming the stage and what would lift it
*/
public void check(final Long savingsAccountId, final Operation operation) {
checkDormancy(savingsAccountId, operation);
checkKycExpiry(savingsAccountId, operation);
}
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
final Long daysOverdue = daysOverdue(savingsAccountId);
if (daysOverdue == null) {
return;
}
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
if (stage == null || stage.isGrace()) {
// Inside the grace window nothing is restricted; the customer is only being chased.
return;
}
// Past grace, a credit is the one thing still let through, until the account is inoperative.
if (operation == Operation.CREDIT && !stage.isInoperative()) {
return;
}
throw new PlatformDataIntegrityException("error.msg.client.kyc.lapsed." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed. The customer's periodic re-KYC lapsed " + daysOverdue
+ " days ago. Complete the re-KYC to lift this.");
}
/**
* Refuses money leaving, or moving, on an account nobody has operated for the product's
* dormancy period.
*
* <p>
* Checked alongside the re-KYC ladder because the two restrict for the same reason and are
* lifted by the same act: an account untouched for two years is the one an impersonator
* reaches for, and RBI allows it to be operated again only once the customer's identity has
* been established afresh. Deposits are left alone — money coming in cannot be a fraud against
* the customer — but a transfer moves the balance somewhere a debit block does not follow, so
* it is refused in either direction.
*
* <p>
* The days are counted from the same clock the ladder measures, so an account that crossed the
* threshold before the nightly job stamped it is caught all the same.
*/
private void checkDormancy(final Long savingsAccountId, final Operation operation) {
if (savingsAccountId == null || operation == Operation.CREDIT) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sp.days_to_dormancy,
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
- GREATEST(sa.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
sa.activatedon_date)) AS days_idle
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
""", savingsAccountId);
if (rows.isEmpty()) {
return;
}
final Number threshold = (Number) rows.get(0).get("days_to_dormancy");
final Number daysIdle = (Number) rows.get(0).get("days_idle");
if (threshold == null || daysIdle == null || daysIdle.longValue() < threshold.longValue()) {
return;
}
final long years = threshold.longValue() / 365;
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
}
/**
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
*
* <p>
* Measured against the business date, not the wall clock, so a branch working a backdated day
* sees the restrictions that applied on that day.
*/
public Long daysOverdue(final Long savingsAccountId) {
if (savingsAccountId == null) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
FROM m_savings_account sa
JOIN m_client_kyc k ON k.client_id = sa.client_id
WHERE sa.id = ? AND k.expiry_date IS NOT NULL
ORDER BY k.expiry_date DESC
LIMIT 1
""", savingsAccountId);
if (rows.isEmpty()) {
return null;
}
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
}
}
@@ -66,6 +66,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
return new KycRiskRuleData( return new KycRiskRuleData(
rule.getId(), rule.getId(),
rule.getRuleName(), rule.getRuleName(),
rule.isKycRule() ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML,
rule.getLegalFormId(), rule.getLegalFormId(),
occCatId, occCatId,
occCatName, occCatName,
@@ -59,6 +59,8 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
// inserting into the middle of the catalog would re-prioritise existing rules. // inserting into the middle of the catalog would re-prioritise existing rules.
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition(); final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
final String ruleKind = resolveRuleKind(request);
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(), final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(), request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(), request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
@@ -67,7 +69,7 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"), StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"), StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"), StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE")); StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"), ruleKind);
if (request.getOccupationCategoryCvId() != null) { if (request.getOccupationCategoryCvId() != null) {
final CodeValue occupationCategory = this.codeValueRepository final CodeValue occupationCategory = this.codeValueRepository
@@ -88,6 +90,44 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
return this.readPlatformService.retrieveRule(saved.getId()); return this.readPlatformService.retrieveRule(saved.getId());
} }
/**
* Decides whether a newly authored rule takes part in onboarding risk categorisation.
*
* An explicit {@code ruleKind} from the caller wins. Otherwise the kind is inferred once, at
* authoring time, from what the request actually describes: a rule carrying live-transaction
* settings is a monitoring scenario, and so is a criteria-less rule - it names no occupation,
* no legal form and no income or turnover band, so as a KYC rule it would match every customer
* who ever walks in. That is precisely how "Additional RFIs Rule - 91" came to categorise a
* salaried employee as Medium Risk and flag them for AML.
*
* Inferring here rather than during evaluation is the point: the answer is written to the row
* and stays stable, so a later migration touching the criteria columns cannot silently change
* how an existing rule is treated.
*/
private String resolveRuleKind(final KycRiskRuleRequest request) {
if (StringUtils.isNotBlank(request.getRuleKind())) {
return ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim()) ? ClientRiskRule.KIND_AML
: ClientRiskRule.KIND_KYC;
}
if (request.hasTransactionRule()) {
return ClientRiskRule.KIND_AML;
}
return hasKycCriteria(request) ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML;
}
/** A bound of zero is "not configured", the same reading the risk calculator applies. */
private boolean hasKycCriteria(final KycRiskRuleRequest request) {
return request.getLegalFormId() != null || request.getOccupationCategoryCvId() != null
|| request.getOccupationCvId() != null || isConfigured(request.getMinMonthlyIncome())
|| isConfigured(request.getMaxMonthlyIncome()) || isConfigured(request.getMinAnnualIncome())
|| isConfigured(request.getMaxAnnualIncome()) || isConfigured(request.getMinAnnualTurnover())
|| isConfigured(request.getMaxAnnualTurnover());
}
private boolean isConfigured(final BigDecimal amount) {
return amount != null && amount.compareTo(BigDecimal.ZERO) > 0;
}
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) { private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
// The band's lower bound is what the rule fires at unless an explicit threshold is given, // The band's lower bound is what the rule fires at unless an explicit threshold is given,
// so a rule described purely as a band is still evaluable against live transactions. // so a rule described purely as a band is still evaluable against live transactions.
@@ -114,6 +154,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100); validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500); validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3); validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
if (StringUtils.isNotBlank(request.getRuleKind()) && !ClientRiskRule.KIND_KYC.equalsIgnoreCase(request.getRuleKind().trim())
&& !ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim())) {
validator.reset().parameter("ruleKind").value(request.getRuleKind()).failWithCode("must.be.kyc.or.aml");
}
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull() validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
.integerGreaterThanZero(); .integerGreaterThanZero();
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull() validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
@@ -0,0 +1,90 @@
/**
* 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.denominationmaster.service;
/**
* The one definition of which {@code m_savings_txn_denomination} rows are real counted cash.
*
* <p>
* Written here once because it is read from four places -- the branch day register's close preview,
* the cashier position report, the cash reconciliation screen and the withdrawal stock check -- and
* those places disagreeing is what makes a drawer hold one figure on one screen and another on the
* next. A change to the rule belongs in this file and nowhere else.
*
* <h3>What the rule has to separate</h3>
*
* <p>
* The {@code txn_id} column is a bigint shared by every register that counts notes, and each writes
* a different kind of id into it:
*
* <ul>
* <li>a savings, current or deposit transaction writes its own transaction id -- always small;
* <li>a cashier deposit request writes its request id plus a fixed offset -- also small;
* <li>a Demand Draft issue writes its own request id -- small;
* <li>a manual GL entry has no numeric id at all, so its hex transaction reference is decoded into
* this column (see {@code CashierGlDenominationApiResource}, matching the encoding
* {@code CashReportApiResource}'s voucher lookup decodes back out of hex) -- almost always very
* large, but a hex string beginning with low digits can land anywhere;
* <li>and older code once wrote {@code System.currentTimeMillis()} here, leaving rows belonging to
* no transaction at all. Those are phantom cash: a cashier's position is cumulative, so they are
* carried forward for ever, and one database held ₹7.29 lakh of them.
* </ul>
*
* <h3>Why this is not a magnitude test</h3>
*
* <p>
* A magnitude test cannot tell the last two apart, and a wider version of this rule -- "reject
* anything at or above 10^12" -- rejected every manual GL entry's cash along with the phantom rows.
* A narrower band still misses: one live row carries a decoded GL reference of 6,547,106,798,661,
* which sits squarely inside the 13-digit band a millisecond epoch occupies.
*
* <p>
* What actually separates them is that a millisecond epoch stamped at write time decodes back to
* approximately the day the row itself belongs to, while a hex reference that happens to land in the
* same band decodes to an arbitrary date -- the row above reads as June 2177 against a June 2026
* transaction date. So the test reads the id as a date and asks whether it lands on the row's own
* day. That is self-maintaining: it needs no constant moved forward as real timestamps grow.
*/
public final class CountedCashLedger {
private CountedCashLedger() {}
/** Widest gap still read as "this id is the moment the row was written", in days. */
private static final int SAME_DAY_TOLERANCE_DAYS = 7;
/**
* SQL predicate keeping only rows whose {@code txn_id} is a real reference, as an expression over
* the alias {@code std} ({@code m_savings_txn_denomination}).
*
* <p>
* The date decoding is confined to the 13-digit band, where {@code to_timestamp} is always inside
* PostgreSQL's own timestamp range; ids outside that band are kept without decoding, so no value
* this column can hold can overflow the comparison.
*
* <p>
* Padded with a space at each end so it reads the same spliced into a text block -- which strips
* the trailing space off the {@code AND } it follows -- as concatenated onto a plain string.
*/
public static final String REAL_TXN_ID = " (std.txn_id IS NULL"
+ " OR std.txn_id < 1000000000000 OR std.txn_id >= 10000000000000"
+ " OR COALESCE(std.txn_date, CAST(std.created_at AS DATE)) IS NULL"
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
}
@@ -126,8 +126,17 @@ public class DenominationBreakdownWriter {
"Denomination breakdown validation errors exist.", errors); "Denomination breakdown validation errors exist.", errors);
} }
// 5. Grand total must match the transaction amount // 5. Grand total must match the transaction amount, to within a rupee.
if (expectedAmount != null && computedTotal.compareTo(expectedAmount) != 0) { //
// Exact equality made a whole class of legitimate transaction impossible to complete: an
// interest-bearing withdrawal or closure settles on a paisa figure (₹12,998.56), Indian cash
// has no paisa coins, so NO set of notes can ever sum to it and every breakdown the cashier
// entered was rejected. Sub-rupee is therefore accepted as the rounding it is -- the same
// tolerance CashReportApiResource's own breakdown check applies on the reporting side, so
// the two agree instead of one accepting what the other rejects. A genuinely mis-keyed
// breakdown (₹1,60,000 of notes against a ₹918 deposit) is off by far more than a rupee and
// is still refused.
if (expectedAmount != null && computedTotal.subtract(expectedAmount).abs().compareTo(BigDecimal.ONE) >= 0) {
throw new PlatformDataIntegrityException( throw new PlatformDataIntegrityException(
"error.msg.denomination.total.mismatch", "error.msg.denomination.total.mismatch",
label + " denomination breakdown total (" + computedTotal label + " denomination breakdown total (" + computedTotal
@@ -277,6 +286,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'" + " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -300,6 +310,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'" + " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -309,7 +320,13 @@ public class DenominationBreakdownWriter {
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'" + " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)" + " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
+ " GROUP BY ced.denomination_id" + " GROUP BY ced.denomination_id"
+ " ) raw GROUP BY denomination_id HAVING SUM(net_count) > 0" // No "HAVING SUM(net_count) > 0" here: dropping the denominations that net negative
// reports MORE cash than the drawer holds, because the shortfall they represent is
// silently discarded instead of subtracted. Net -20x₹500 with +30x₹100 is -₹7,000,
// not +₹3,000 -- and this figure is what validateCashierHasTotal lets a withdrawal
// through on, so the optimistic version hands out money that is not there. It also
// disagreed with getCashierNetPosition below, which has never filtered them.
+ " ) raw GROUP BY denomination_id"
+ ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id"; + ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id";
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class,
@@ -390,6 +407,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'" + " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -413,6 +431,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'" + " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -45,6 +45,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -94,12 +96,11 @@ public class DividendClaimRequestApiResource {
private final VoucherService voucherService; private final VoucherService voucherService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); 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; /** The dividend GL accounts, as configured on Accounting > Financial Activity Mappings. */
private final DividendGlAccountResolver dividendGlAccounts;
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */ /** A dividend payout, keyed by its m_share_dividend_claim_request id. */
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST"; 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_DEBIT = 2;
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
@@ -492,23 +493,31 @@ public class DividendClaimRequestApiResource {
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration * 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. * and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
*/ */
/**
* The DD Payable liability a dividend draft is raised against.
*
* m_sponsor_dd_gl_mapping holds a single global row — it was keyed by payment type until 0349 dropped that column,
* and the read side reports it as "Global" — so there is one mapping for every sponsor bank, and the first row is
* the only row. Kept as its own method so this reads as a deliberate choice rather than an arbitrary LIMIT 1, and
* so it fails loudly if the mapping has not been configured at all.
*/
private Long ddPayableAccount() {
final Long glAccountId = 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 (glAccountId == null) {
throw new IllegalStateException("No Sponsor DD Payable GL account is configured. Set it under "
+ "Organization > Sponsor DD GST / GL Settings before approving a dividend claim.");
}
return glAccountId;
}
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode, private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
final BigDecimal amount, final LocalDate today, final AppUser currentUser) { final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
final Long payableDividendsAccountId = jdbcTemplate final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, final Long dividendPaidAccountId = dividendGlAccounts.dividendPaidAccountOrNull();
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) final Long ddPayableAccountId = ddPayableAccount();
.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 (payableDividendsAccountId == null || ddPayableAccountId == null) {
return;
}
final java.sql.Date entryDate = java.sql.Date.valueOf(today); final java.sql.Date entryDate = java.sql.Date.valueOf(today);
final String transactionId = "DIVDD" + claimId; final String transactionId = "DIVDD" + claimId;
@@ -587,6 +596,8 @@ public class DividendClaimRequestApiResource {
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description, final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
final AppUser currentUser) { final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
final String insertSql = """ final String insertSql = """
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -98,6 +98,7 @@ public class BranchProfileApiResource {
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root); final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root); final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
final String address = this.fromJsonHelper.extractStringNamed("address", root); final String address = this.fromJsonHelper.extractStringNamed("address", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
@@ -124,22 +125,22 @@ public class BranchProfileApiResource {
if (existing != null && existing > 0) { if (existing != null && existing > 0) {
jdbcTemplate.update(""" jdbcTemplate.update("""
UPDATE m_office_profile UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?, SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?, working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ? updated_by_username = ?, updated_at = ?
WHERE office_id = ? WHERE office_id = ?
""", branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId); Timestamp.from(Instant.now()), officeId);
} else { } else {
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO m_office_profile INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, contact_phone, contact_email, (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at) latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now())); Timestamp.from(Instant.now()));
} }
@@ -150,7 +151,7 @@ public class BranchProfileApiResource {
private String baseSql() { private String baseSql() {
return """ return """
SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date, 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.branch_code, p.branch_type, p.address, p.pincode, p.contact_phone, p.contact_email,
p.working_hours_start, p.working_hours_end, p.working_days, p.working_hours_start, p.working_hours_end, p.working_days,
p.ifsc_code, p.micr_code, p.latitude, p.longitude, p.ifsc_code, p.micr_code, p.latitude, p.longitude,
p.updated_by_username, p.updated_at p.updated_by_username, p.updated_at
@@ -169,6 +170,7 @@ public class BranchProfileApiResource {
out.put("branchCode", row.get("branch_code")); out.put("branchCode", row.get("branch_code"));
out.put("branchType", row.get("branch_type")); out.put("branchType", row.get("branch_type"));
out.put("address", row.get("address")); out.put("address", row.get("address"));
out.put("pincode", row.get("pincode"));
out.put("contactPhone", row.get("contact_phone")); out.put("contactPhone", row.get("contact_phone"));
out.put("contactEmail", row.get("contact_email")); out.put("contactEmail", row.get("contact_email"));
out.put("workingHoursStart", row.get("working_hours_start")); out.put("workingHoursStart", row.get("working_hours_start"));
@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.savings.service.DeafRemittanceWritePlatformService;
import org.springframework.stereotype.Component;
/**
* Records remittances of escheated balances to RBI's Depositor Education and Awareness Fund.
*
* <p>
* The transfer to RBI happens outside this system; these endpoints only write down that it was
* made, so the escheat report can distinguish money that reached the fund from money that has left
* the customer's account and gone nowhere.
*/
@Path("/v1/savingsaccounts/deaf-remittance")
@Component
@Tag(name = "DEAF Remittance", description = "Record remittance of escheated balances to RBI's DEAF.")
@RequiredArgsConstructor
public class DeafRemittanceApiResource {
private final DeafRemittanceWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
private final FromJsonHelper fromJsonHelper;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a DEAF remittance", description = "Marks the escheated balances of the given savings accounts "
+ "as remitted to RBI. Accounts that are not escheated, or already recorded, are skipped.")
public String recordRemittance(final String apiRequestBodyAsJson) {
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonCommand command = JsonCommand.fromJsonElement(null, parsed, this.fromJsonHelper);
final CommandProcessingResult result = this.writePlatformService.recordRemittance(command);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{savingsAccountId}/refund")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a claim against a remitted balance", description = "Marks that the customer claimed the balance "
+ "back after it had gone to the fund, and the bank recovered it from RBI.")
public String markRefunded(@PathParam("savingsAccountId") final Long savingsAccountId, final String apiRequestBodyAsJson) {
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonCommand command = JsonCommand.fromJsonElement(savingsAccountId, parsed, this.fromJsonHelper);
final CommandProcessingResult result = this.writePlatformService.markRefunded(savingsAccountId, command);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,59 @@
/**
* 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.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
import org.springframework.stereotype.Component;
/**
* The read side of the dormancy ladder: which accounts the nightly job has marked, and how long
* each has been idle.
*/
@Path("/v1/savingsaccounts/dormant")
@Component
@Tag(name = "Dormant Savings Accounts", description = "Accounts marked inactive, dormant or escheated by the dormancy job.")
@RequiredArgsConstructor
public class DormantAccountsApiResource {
private final DormantAccountReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<DormantAccountData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve dormant accounts", description = "Accounts at any of the three dormancy marks, longest idle first. "
+ "Pass subStatus to narrow to one: 100 inactive, 200 dormant, 300 escheat.")
public String retrieveDormantAccounts(@QueryParam("subStatus") final Integer subStatus,
@QueryParam("officeName") final String officeName) {
final List<DormantAccountData> accounts = this.readPlatformService.retrieveDormantAccounts(subStatus, officeName);
return this.toApiJsonSerializer.serialize(accounts);
}
}
@@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** /**
* Runs the day: accrue for every account, then pay out any product whose cycle ended today. * Runs the day: accrue for every account, then pay out any product whose cycle ended today.
@@ -47,9 +48,21 @@ public class SavingsDailyInterestOrchestrator {
private final SavingsDailyInterestService accrualService; private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService; private final SavingsPooledInterestPostingService postingService;
/** Accrue for the day, then post for any product whose cycle ends today. */ /**
* Accrue for the day, then post for any product whose posting date has arrived.
*
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
* posting then reads and updates those same rows in a transaction of its own
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
* another's payment). Holding an outer transaction across both would mean this thread still owns
* the row locks the accrual took while the posting's separate transaction waits for them — each
* waiting on the other, and the job hanging silently with nothing in the log. So each step
* commits on its own before the next begins.
*/
public void runForDate(final LocalDate businessDate) { public void runForDate(final LocalDate businessDate) {
clearStaleRunningFlag();
accrualService.accrueForDate(businessDate); accrualService.accrueForDate(businessDate);
enrolNewProducts();
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList(""" final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
SELECT s.savings_product_id, s.posting_period_enum SELECT s.savings_product_id, s.posting_period_enum
@@ -59,33 +72,152 @@ public class SavingsDailyInterestOrchestrator {
ORDER BY s.savings_product_id ORDER BY s.savings_product_id
"""); """);
// Products that could not be paid, collected so the run can fail loudly at the end.
//
// Carrying on past one product is right — a mapping missing on one product must not stop
// every member of every other from being paid. Reporting the job as successful afterwards is
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
// nothing reads. On a night when every product fails, that is a whole day of interest
// silently unpaid.
final List<String> failures = new java.util.ArrayList<>();
for (final Map<String, Object> schedule : schedules) { for (final Map<String, Object> schedule : schedules) {
final long productId = ((Number) schedule.get("savings_product_id")).longValue(); 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;
}
// Whether anything is due is decided inside postForProduct, not here.
//
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
// deadlocked the run: accrueForDate above is @Transactional and has just written every
// ledger row, so its transaction still holds them; the COUNT then read those rows on
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
// transaction of its own — sat waiting for a lock this thread was holding and would only
// release after postForProduct returned. Each waited on the other and the job hung
// silently, with nothing in the log and the flag left saying it was still running.
//
// postForProduct already selects what is due and returns zero when there is nothing, so
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
// than by the run date, which is what makes a missed posting date catch up on a later
// run instead of being skipped for good.
try { try {
final BigDecimal paid = postingService.postForProduct(productId, businessDate); final BigDecimal paid = postingService.postForProduct(productId, businessDate);
if (paid.signum() == 0) {
updateSchedule(productId, businessDate, null);
continue;
}
updateSchedule(productId, businessDate, "COMPLETED"); updateSchedule(productId, businessDate, "COMPLETED");
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate); log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
} catch (final RuntimeException e) { } catch (final RuntimeException e) {
// The product rolled back on its own; record why and let the others run. // 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); log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
// the row — which is exactly what a product with no GL mapping does. The failure is
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
jdbcTemplate.update(""" jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ? SET status = 'FAILED', failure_reason = ?
WHERE savings_product_id = ? AND posting_date = ? WHERE savings_product_id = ? AND posting_date = ?
""", truncate(e.getMessage()), productId, businessDate); """, truncate(e.getMessage()), productId, businessDate);
updateSchedule(productId, businessDate, "FAILED"); updateSchedule(productId, businessDate, "FAILED");
failures.add("product " + productId + ": " + e.getMessage());
} }
} }
// Fails the job so the scheduler records it as failed and an operator is told. Every product
// that could be paid has already been paid and committed by this point — this reports what
// happened, it does not undo it.
if (!failures.isEmpty()) {
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
+ failures.size() + " product(s): " + String.join("; ", failures));
}
}
/**
* Releases this job's own currently_running flag if an earlier run left it set.
*
* The platform sets that flag in the scheduler's veto check before a job's body runs, and clears
* it in the Quartz listener when the body returns. Two things leave it set with nothing running:
* a JVM stopped mid-run, and a run started through this service's own API rather than through
* Quartz — the listener is never involved there, so nothing ever clears it.
*
* Once set, the veto silently drops every later trigger: no run-history row, no error, nothing in
* the log. The job reads as though it ran and did nothing, which is exactly how a night of unpaid
* interest goes unnoticed. It happened here — a manual API run left the flag behind and the job
* was mute from then on.
*
* Reaching this line is itself the proof that no other run is in flight: Quartz will not start a
* second execution of a job it is already running, and this is the first statement of the only
* method that runs one. So the flag can only be a leftover, and clearing it means one dead or
* out-of-band run cannot mute the job for good.
*/
private void clearStaleRunningFlag() {
final int cleared = jdbcTemplate.update("""
UPDATE job SET currently_running = false
WHERE name = 'Savings Daily Interest Accrual And Posting' AND currently_running = true
""");
if (cleared > 0) {
log.warn("Savings interest: cleared a stale currently_running flag left by an earlier run");
}
}
/**
* Enrols the product behind one account, if it is not already on the timetable.
*
* <p>
* Called when an account is activated, so the first account opened on a brand-new product brings
* that product onto the timetable rather than the product waiting for the nightly sweep. The
* insert is guarded the same way as {@link #enrolNewProducts()}, so calling it for a product
* already enrolled does nothing.
*/
@Transactional
public void enrolProductOf(final Long savingsAccountId) {
try {
final int enrolled = jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_schedule
(savings_product_id, posting_period_enum, run_at_time, is_active)
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
AND sp.deposit_type_enum = 100
AND sp.interest_posting_period_enum IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
WHERE s.savings_product_id = sp.id)
""", savingsAccountId);
if (enrolled > 0) {
log.info("Savings interest: enrolled the product behind account {} into the daily-interest schedule", savingsAccountId);
}
} catch (final RuntimeException e) {
// Called from activation, which must not be rolled back because the timetable could not
// be written. The nightly sweep enrols the product anyway.
log.error("Savings interest: could not enrol the product behind account {}; the nightly run will do it",
savingsAccountId, e);
}
}
/**
* The nightly sweep: gives any savings product without a timetable one, on its own posting period.
*
* <p>
* Activation enrols a product as soon as its first account opens, so this is the safety net —
* it catches a product whose accounts were activated while this was not yet wired, or where the
* enrolment on activation failed. The period comes from the product's own
* {@code interest_posting_period_enum}, so a product set to post monthly posts monthly.
*/
private void enrolNewProducts() {
final int enrolled = jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_schedule
(savings_product_id, posting_period_enum, run_at_time, is_active)
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
FROM m_savings_product sp
WHERE sp.deposit_type_enum = 100
AND sp.interest_posting_period_enum IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
WHERE s.savings_product_id = sp.id)
""");
if (enrolled > 0) {
log.info("Savings interest: enrolled {} new savings product(s) into the daily-interest schedule", enrolled);
}
} }
/** /**
@@ -23,6 +23,7 @@ import java.math.MathContext;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -118,6 +119,62 @@ public class SavingsDailyInterestService {
return written; return written;
} }
/**
* Opens the ledger for one newly activated account.
*
* <p>
* The nightly run would pick the account up on its own, but not until tomorrow, and until then
* the account is simply absent from the ledger — which reads as though it is not being accrued at
* all. Called from activation, it writes the account's first row there and then, so an account is
* on the ledger from the day it opens.
*
* <p>
* Only savings accounts are accrued. Anything else — a term or recurring deposit — is left alone,
* and a failure is logged rather than raised: the activation itself has succeeded and must not be
* rolled back because the ledger could not be opened. The nightly run will pick it up.
*
* <p>
* Runs in the caller's transaction, deliberately. Activation sets the account active in that
* transaction and has not committed yet, so a REQUIRES_NEW transaction here would read the row as
* it was before — still approved, not yet active — find nothing to accrue, and return quietly
* having done nothing. Joining the caller sees the activation, and the status is not re-checked
* for the same reason: the value in the database is the pre-activation one until commit.
*
* @return true if a row was written
*/
@Transactional
public boolean accrueOnActivation(final Long savingsAccountId, final LocalDate businessDate) {
try {
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 sa.id = ? AND sp.deposit_type_enum = ?
AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ?
""", savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate);
if (accounts.isEmpty()) {
return false;
}
final boolean written = accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth());
if (written) {
log.info("Savings daily interest: opened the ledger for newly activated account {}", savingsAccountId);
}
return written;
} catch (final RuntimeException e) {
log.error("Savings daily interest: could not open the ledger for account {} on activation; the nightly run will "
+ "pick it up", savingsAccountId, e);
return false;
}
}
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate, private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
final int financialYearBeginningMonth) { final int financialYearBeginningMonth) {
final Long accountId = ((Number) account.get("id")).longValue(); final Long accountId = ((Number) account.get("id")).longValue();
@@ -130,16 +187,27 @@ public class SavingsDailyInterestService {
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate); final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
final BigDecimal minBalance = (BigDecimal) account.get("min_balance"); final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
// The previous row is the only thing that carries the chain forward. Its absence means // The account's row is the only thing that carries the chain forward: it holds the balance
// this is the account's first day; a gap would mean a day the job never ran, and rather // the last accrued day closed on and the cycle's running total. Its absence means this is
// than silently re-anchoring we take the last row we do have and let the reconciliation // the account's first day.
// report the break. //
// The ledger keeps one row per account, so the row is read and then rewritten in place with
// today's figures. Only a row for a day before today can be carried forward — one already
// stamped with today or later means the day has been accrued and re-reading it as "previous"
// would compound the same day's interest onto itself.
// Newest first, one row. The table currently carries a unique constraint on
// savings_account_id so at most one row can match and the ordering is academic — but the
// code below reads previous.get(0) as "yesterday", and the moment per-day history is
// restored an unordered query would hand it an arbitrary earlier day as the opening balance
// and silently break the chain. Stating the intent costs nothing and does not depend on the
// constraint staying where it is.
final List<Map<String, Object>> previous = jdbcTemplate.queryForList(""" final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
cycle_start_date, cycle_end_date, is_posting_day cycle_start_date, cycle_end_date, is_posting_day
FROM m_savings_daily_interest FROM m_savings_daily_interest
WHERE savings_account_id = ? AND balance_date < ? WHERE savings_account_id = ? AND balance_date < ?
ORDER BY balance_date DESC LIMIT 1 ORDER BY balance_date DESC
LIMIT 1
""", accountId, businessDate); """, accountId, businessDate);
final BigDecimal openingBalance; final BigDecimal openingBalance;
@@ -149,29 +217,71 @@ public class SavingsDailyInterestService {
final LocalDate cycleEnd; final LocalDate cycleEnd;
if (previous.isEmpty()) { if (previous.isEmpty()) {
openingBalance = BigDecimal.ZERO;
carriedInterest = BigDecimal.ZERO;
cycleNumber = 1; cycleNumber = 1;
// Cycle 1 is a stub: it starts when the account was activated, not on the grid. // The first row belongs to the cycle the day it records falls in, and starts at
cycleStart = activatedOn; // activation only when that day is still inside the account's opening cycle. Anchoring
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth); // on activation alone dates the row into a cycle that closed months ago: an account
// opened in June, first accrued in September, would be stamped with June's cycle end
// and sit there permanently past its own posting date.
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
financialYearBeginningMonth);
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart;
// The account has earned since its cycle opened, but the ledger only starts today —
// either because the account predates this ledger, or because the nightly run has not
// reached it before now. Taking the opening balance as zero and the carried interest as
// nothing would credit the member for today alone and silently drop every day since
// cycleStart, which is exactly what a closure then pays out.
//
// So the days already gone are replayed here, from the account's own transactions, to
// arrive at the balance and the running interest the ledger would be holding had it
// been running all along.
final Backfill backfill = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
openingBalance = backfill.closingBalance();
carriedInterest = backfill.cumulativeInterest();
} else { } else {
final Map<String, Object> prev = previous.get(0); final Map<String, Object> prev = previous.get(0);
openingBalance = (BigDecimal) prev.get("closing_balance"); openingBalance = (BigDecimal) prev.get("closing_balance");
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
if (prevWasPosting) { // A cycle is finished either because it was paid out or because its end date has gone
// The cycle closed yesterday: the sum went to the member and starts again at zero. // by. The second case happens whenever the ledger has a gap over a posting date - the
// run was missed, or the account was accrued again after a break - and treating it as
// unfinished would leave the row in a cycle that ended before the day it records.
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
carriedInterest = BigDecimal.ZERO; carriedInterest = BigDecimal.ZERO;
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
cycleStart = prevCycleEnd.plusDays(1); cycleStart = prevCycleEnd.plusDays(1);
cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod, cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth); financialYearBeginningMonth);
} else { } else {
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
cycleNumber = ((Number) prev.get("cycle_number")).intValue(); cycleNumber = ((Number) prev.get("cycle_number")).intValue();
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate(); cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
cycleEnd = prevCycleEnd; cycleEnd = prevCycleEnd;
// Days between the last row and today are earned days too, and have to be added.
//
// The ledger keeps one row per account, so the "previous" row is simply the last day
// the job ran for this account — not necessarily yesterday. Where nights were missed,
// or a business date was moved forward by more than a day, every day in between is a
// day the member held a balance and earned on it. Carrying the previous row's total
// forward unchanged and adding only today's interest silently drops all of them: an
// account activated on the 8th and accrued again on the 30th was credited two days'
// interest instead of twenty-three.
//
// So the gap is replayed from the account's own transactions, the same way a first
// row is built, and the result stands in for the running total the ledger would be
// holding had it run every night.
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
carriedInterest = gap.cumulativeInterest();
} else {
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
}
} }
} }
@@ -180,8 +290,7 @@ public class SavingsDailyInterestService {
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against // 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. // whichever balance the account actually earns on, which is not always today's closing.
final boolean averageDailyBalance = calcType != null final boolean averageDailyBalance = averageDailyBalanceOf(calcType);
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
final BigDecimal dailyInterest; final BigDecimal dailyInterest;
final BigDecimal cumulative; final BigDecimal cumulative;
@@ -232,26 +341,36 @@ public class SavingsDailyInterestService {
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum, 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) posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET ON CONFLICT (savings_account_id) DO UPDATE SET
balance_date = EXCLUDED.balance_date,
opening_balance = EXCLUDED.opening_balance, opening_balance = EXCLUDED.opening_balance,
-- A day whose interest has already been paid keeps the balance it was left -- Taken as computed. The interest this ledger posts is counted as movement on
-- with. Re-accruing that day recomputes the closing balance from -- the day it was paid (see dayMovement), so the recomputed closing already
-- transactions alone, which does not include this ledger's own posting, and -- carries it and needs no adjustment here.
-- writing it back would erase the credit from the chain: the next day would closing_balance = EXCLUDED.closing_balance,
-- 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, daily_interest = EXCLUDED.daily_interest,
-- Likewise the total that was actually paid: once money has moved on the -- Likewise the total that was actually paid: once money has moved on the
-- strength of this figure, recomputing it would leave the ledger disagreeing -- strength of this figure, recomputing the same day would leave the ledger
-- with the member's passbook. A posted day is settled. -- disagreeing with the member's passbook. A posted day is settled — but only
-- that day. The next day starts the new cycle's total from zero, which is what
-- the accrual has computed, so it is taken as given.
cycle_cumulative_interest = CASE cycle_cumulative_interest = CASE
WHEN m_savings_daily_interest.posted_amount IS NOT NULL WHEN m_savings_daily_interest.posted_amount IS NOT NULL
AND m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.cycle_cumulative_interest THEN m_savings_daily_interest.cycle_cumulative_interest
ELSE EXCLUDED.cycle_cumulative_interest END, ELSE EXCLUDED.cycle_cumulative_interest END,
-- Cleared when the row moves to a new day: posted_amount and the transaction
-- behind it belong to the day they were paid on, and carrying them onto the
-- next day would make an unposted cycle look settled.
posted_amount = CASE
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.posted_amount ELSE NULL END,
savings_transaction_id = CASE
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.savings_transaction_id ELSE NULL END,
posting_run_id = CASE
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.posting_run_id ELSE NULL END,
cycle_number = EXCLUDED.cycle_number, cycle_number = EXCLUDED.cycle_number,
cycle_start_date = EXCLUDED.cycle_start_date, cycle_start_date = EXCLUDED.cycle_start_date,
cycle_end_date = EXCLUDED.cycle_end_date, cycle_end_date = EXCLUDED.cycle_end_date,
@@ -299,16 +418,181 @@ public class SavingsDailyInterestService {
} }
/** /**
* The day's net movement, excluding interest postings. * The day's net movement, interest postings included.
* *
* Interest postings are left out on purpose. The posting is this ledger's own output, and * Interest credited by this ledger counts as movement like any other credit. It has to: the
* folding it back in here would let it accrue interest on itself a second time. It is added to * ledger holds one row per account, so a balance is only ever built from the row before it, and
* the closing balance by the posting run, on the posting day, once. * a posting left out of the movement is a posting missing from every balance that follows. The
* member is paid, their passbook shows it, and the ledger would go on earning on the balance
* they had before - the interest silently stops compounding.
*
* It cannot double-count, because a posting only ever falls on one day and the row for that day
* keeps the balance the posting left it with (see the closing_balance guard in the upsert).
*/ */
/**
* Brings one account's ledger up to the business date after its balance has moved.
*
* <p>
* The ledger is written by the nightly run, so between a deposit and the next run the account's
* row still states yesterday's balance and yesterday's interest. A closure in that window reads
* the stale row and pays out interest on a balance the member no longer has — or, for money paid
* in today, on no balance at all. Calling this as soon as the balance moves keeps the row in step
* with the account, so what a closure reads is what the account actually holds.
*
* <p>
* Re-accruing the same day is safe. The row is rewritten in place from the day before it, never
* from itself, so running this repeatedly through a busy day converges on the same figures rather
* than compounding them. An account with no row yet gets its first one here, backfilled from the
* cycle start exactly as {@link #accrueOnActivation} would have written it.
*
* <p>
* Never fails the caller: the transaction that moved the money has already succeeded and must not
* be rolled back because the ledger could not be refreshed. A failure is logged and left for the
* nightly run.
*/
@Transactional
public void refreshAfterBalanceChange(final Long savingsAccountId, final LocalDate businessDate) {
try {
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 sa.id = ? AND sp.deposit_type_enum = ?
AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ?
""", savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate);
if (accounts.isEmpty()) {
return;
}
accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth());
} catch (final RuntimeException e) {
log.error("Savings daily interest: could not refresh the ledger for account {} after a balance change; the "
+ "nightly run will pick it up", savingsAccountId, e);
}
}
private boolean averageDailyBalanceOf(final Number calcType) {
return calcType != null && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
}
/** The balance and running interest the ledger would hold on the eve of its first row. */
private record Backfill(BigDecimal closingBalance, BigDecimal cumulativeInterest) {}
/**
* Replays the days between a cycle's start and the first day the ledger records, so an account
* whose ledger opens mid-cycle carries in the interest it has already earned.
*
* <p>
* Without this the first row starts from zero on both counts, and every day from cycleStart up to
* the day before is lost — the member is credited for one day of a cycle they have been in for
* weeks. It is only reachable on an account's very first row: once the ledger has a row, the chain
* carries itself forward.
*
* <p>
* The replay walks the account's own transactions, so it reconstructs the same daily closing
* balances the nightly run would have written, including days the balance changed. Days before the
* account's activation cannot earn and are not walked — cycleStart is already clamped to activation
* by the caller. Reversed transactions are excluded exactly as {@link #dayMovement} excludes them.
*
* @return the closing balance and cumulative interest as at {@code upTo.minusDays(1)}
*/
private Backfill backfillFromCycleStart(final Long accountId, final LocalDate cycleStart, final LocalDate upTo,
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
if (!cycleStart.isBefore(upTo)) {
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
}
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
// cycleStart is carried in as the opening figure rather than replayed day by day: interest is
// earned within the cycle, but the money that earns it may have been deposited long before.
final BigDecimal openingBalance = balanceAsOf(accountId, cycleStart.minusDays(1));
final Map<LocalDate, BigDecimal> movements = movementsBetween(accountId, cycleStart, upTo.minusDays(1));
BigDecimal balance = openingBalance;
BigDecimal cumulative = BigDecimal.ZERO;
BigDecimal balanceDaysTotal = BigDecimal.ZERO;
int days = 0;
for (LocalDate day = cycleStart; day.isBefore(upTo); day = day.plusDays(1)) {
balance = balance.add(movements.getOrDefault(day, BigDecimal.ZERO));
balanceDaysTotal = balanceDaysTotal.add(balance);
days++;
if (!averageDailyBalance) {
final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
&& balance.compareTo(minBalance) < 0;
if (!belowMinimum && balance.compareTo(BigDecimal.ZERO) > 0 && rate != null
&& rate.compareTo(BigDecimal.ZERO) > 0) {
cumulative = cumulative.add(balance.multiply(rate, MC)
.divide(BigDecimal.valueOf(100L), MC)
.divide(BigDecimal.valueOf(daysInYear), MC));
}
}
}
if (averageDailyBalance) {
// The same shape the daily path uses: the cycle's average balance, carried across the days
// walked, rather than a sum of per-day figures.
final BigDecimal averageBalance = balanceDaysTotal.divide(BigDecimal.valueOf(days), MC);
final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
&& averageBalance.compareTo(minBalance) < 0;
if (!belowMinimum && averageBalance.compareTo(BigDecimal.ZERO) > 0 && rate != null
&& rate.compareTo(BigDecimal.ZERO) > 0) {
cumulative = averageBalance.multiply(rate, MC)
.divide(BigDecimal.valueOf(100L), MC)
.divide(BigDecimal.valueOf(daysInYear), MC)
.multiply(BigDecimal.valueOf(days), MC);
}
}
return new Backfill(balance, cumulative);
}
/** The account's balance at the close of a given day, from its transactions. */
private BigDecimal balanceAsOf(final Long accountId, final LocalDate asOf) {
final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE
WHEN t.transaction_type_enum IN (1, 3, 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, asOf);
return balance == null ? BigDecimal.ZERO : balance;
}
/** Net movement per day across a date range, for days that had any. */
private Map<LocalDate, BigDecimal> movementsBetween(final Long accountId, final LocalDate from, final LocalDate to) {
final Map<LocalDate, BigDecimal> byDay = new HashMap<>();
jdbcTemplate.queryForList("""
SELECT t.transaction_date AS d,
SUM(CASE
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
ELSE 0 END) AS net
FROM m_savings_account_transaction t
WHERE t.savings_account_id = ?
AND t.transaction_date >= ? AND t.transaction_date <= ?
AND t.is_reversed = false
GROUP BY t.transaction_date
""", accountId, from, to)
.forEach(row -> byDay.put(((java.sql.Date) row.get("d")).toLocalDate(), (BigDecimal) row.get("net")));
return byDay;
}
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) { private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
final BigDecimal movement = jdbcTemplate.queryForObject(""" final BigDecimal movement = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE SELECT COALESCE(SUM(CASE
WHEN t.transaction_type_enum IN (1, 8) THEN t.amount WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
ELSE 0 END), 0) ELSE 0 END), 0)
FROM m_savings_account_transaction t FROM m_savings_account_transaction t
@@ -26,9 +26,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/** /**
@@ -40,11 +40,41 @@ import org.springframework.transaction.annotation.Transactional;
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere * 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. * holds the figure that would say so.
* *
* This posts the product's total first, into Savings Interest Posting Control, moves it to Savings * This settles the debt the accrual has already recognised: the product's total moves out of the
* Interest Posted Control, and only then pays it out to members. The whole product settles inside * liability the product names as its Savings Interest Payable, into the account it is paid out of,
* one transaction, and both control accounts have to read zero before it is allowed to commit. A * and only then to the members:
* 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. * <pre>
* Dr Savings Interest Payable (liability, from the product)
* Cr Savings Interest Paid on its way out
*
* Dr Savings Interest Paid (per member)
* Cr Savings Control (per member, from the product) in the member's hands
* </pre>
*
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
* this is the settlement of what that recognition already booked - raising the expense again at
* payout would put the same interest on the P&amp;L twice.
*
* Every account named above is read from the product's own GL configuration; none is written into
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
* where that is unset, from the product's reserved GL mapping of the same name, and failing both
* from its Savings Control - interest owed is a liability either way.
*
* The first step is dropped where it would say nothing, and the payout then comes straight out of
* the payable:
*
* <pre>
* Dr Savings Interest Payable (per member)
* Cr Savings Control (per member)
* </pre>
*
* That happens when the product names no paid account, so both legs of the first step resolve to the
* payable and it would debit and credit one account for the same amount.
*
* The whole product settles inside one transaction. 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.
* *
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two * 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 * decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
@@ -61,26 +91,46 @@ public class SavingsPooledInterestPostingService {
private static final int MONEY_SCALE = 2; private static final int MONEY_SCALE = 2;
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; 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"; * Reserved names in the product's own GL mappings, which is where the middle two legs of the
* interest bill are configured. The names are the contract between this service and the product
* screen — the GL codes behind them are the bank's choice, not ours, so nothing here names an
* account. A product that leaves one unset falls back to another mapping the same product
* carries; no GL code is ever written into this file.
*/
private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable";
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
/** m_savings_account_transaction.transaction_type_enum */ /** m_savings_account_transaction.transaction_type_enum */
private static final int INTEREST_POSTING = 3; private static final int INTEREST_POSTING = 3;
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */ /** acc_gl_journal_entry.entity_type_enum for a savings entry. */
private static final int ENTITY_TYPE_SAVINGS = 2; private static final int ENTITY_TYPE_SAVINGS = 2;
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3; private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2;
/**
* {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product's
* Accounting tab, which is where a bank picks the account interest is owed from.
*/
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
/** /**
* Posts one product's interest for one posting date. * 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 * @return the amount distributed, or zero if there was nothing to do
*
* <p>
* REQUIRED, not REQUIRES_NEW.
*
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
* the orchestrator calls this outside any transaction of its own, so each product opens and
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
* running and waits for locks that transaction may hold. The accrual step immediately before
* writes every account's ledger row, and this method reads and updates those same rows: with
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
* and the job hung with nothing in the log and its currently_running flag left set.
*/ */
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) { public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
final Long completed = jdbcTemplate.queryForObject(""" final Long completed = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_run SELECT COUNT(*) FROM m_savings_interest_posting_run
@@ -91,8 +141,18 @@ public class SavingsPooledInterestPostingService {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
// The accounts due today, with what they accrued. Anything already carrying a posting // Every account whose posting date has arrived or passed and has not yet been paid.
// transaction is skipped, so a resumed run cannot pay somebody a second time. //
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
// run on was never paid at all — the cycle simply rolled over with the interest carried into
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
// to behave when a night is missed.
//
// 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 // 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 // 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. // against that office or they land in the wrong branch's books.
@@ -105,7 +165,7 @@ public class SavingsPooledInterestPostingService {
JOIN m_savings_account sa ON sa.id = d.savings_account_id 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_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_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 WHERE d.savings_product_id = ? AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0 AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL AND COALESCE(c.office_id, g.office_id) IS NOT NULL
@@ -142,22 +202,55 @@ public class SavingsPooledInterestPostingService {
largest.get("savings_account_id")); largest.get("savings_account_id"));
} }
final Long postingControlGl = glAccountId(GL_POSTING_CONTROL); // The three accounts the bill passes through on its way to the members. Every one is read
final Long postedControlGl = glAccountId(GL_POSTED_CONTROL); // from the product's own configuration, so a bank on its own chart of accounts is posted to
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); // the accounts it chose. No GL code is named in this class.
//
// The product's Accounting tab is the authority, and it is consulted first for all three:
// Interest On Savings for the expense, and the standard Interest Payable row for the
// payable. Those are the rows a bank actually fills in on the product screen.
//
// The reserved names in the product's other GL mappings are consulted only after that, as a
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
// which has no standard row of its own.
//
// Where a product sets none of them the bill still has somewhere to go: the payable falls
// back to the product's Savings Control (interest owed is a liability either way), and the
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
// posting a product without these mappings has always had.
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code"); final String currency = (String) due.get(0).get("currency_code");
final String poolReference = "SIP-" + productId + "-" + postingDate; final String poolReference = "SIP-" + productId + "-" + postingDate;
// Stage 1: the product's whole interest bill, recognised and pooled. // Stage 1: the debt the accrual has been building moves out of the payable and into the
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); // account the payout is made from, so what is still merely owed and what is on its way to
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null); // the members are two separate balances rather than one.
//
// The interest expense itself is not raised here. It is recognised as the interest accrues,
// day by day, and posting is the settlement of that recognised debt — raising the expense
// again at payout would count the same interest twice on the P&L.
//
// Skipped where the product names no separate paid account: both legs then resolve to the
// payable and this would debit and credit one account for the same amount, saying nothing.
final boolean payableIsPaid = payableGl.equals(paidGl);
final boolean stage1Applies = !payableIsPaid;
if (stage1Applies) {
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
}
// Stage 2: the pool moves to the account it is paid out of. // Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); // draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); // from there instead — debiting the paid account would take the money out of an account
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
// Stage 3: out to the members, one at a time. // Stage 2: out to the members, one at a time.
BigDecimal distributed = BigDecimal.ZERO; BigDecimal distributed = BigDecimal.ZERO;
for (final Map<String, Object> row : due) { for (final Map<String, Object> row : due) {
final BigDecimal amount = (BigDecimal) row.get("rounded_interest"); final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
@@ -172,11 +265,14 @@ public class SavingsPooledInterestPostingService {
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
newBalance); newBalance);
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference, journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
accountId); accountId);
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId); accountId, transactionId);
// closing_balance is raised here because the interest transaction has just been written
// and the accrual for this day has already run: nothing else will fold it in before the
// next day reads this row as its opening.
jdbcTemplate.update(""" jdbcTemplate.update("""
UPDATE m_savings_daily_interest UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
@@ -184,12 +280,22 @@ public class SavingsPooledInterestPostingService {
WHERE id = ? WHERE id = ?
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue()); """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
// Both interest columns move together, and that is not a duplicate.
//
// total_interest_earned_derived is the interest the account has earned to date;
// total_interest_posted_derived is how much of that has been paid into the balance.
// Screens and validate()'s own check read the unposted interest as earned - posted, so
// raising only `posted` drives that difference negative: an account paid 26.52 with
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
// showed exactly that. The accrual writes pending interest to the ledger and never to
// `earned`, so this posting is the only place `earned` is realised.
jdbcTemplate.update(""" jdbcTemplate.update("""
UPDATE m_savings_account UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ? total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
WHERE id = ? WHERE id = ?
""", amount, amount, accountId); """, amount, amount, amount, accountId);
distributed = distributed.add(amount); distributed = distributed.add(amount);
} }
@@ -249,7 +355,7 @@ public class SavingsPooledInterestPostingService {
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId, 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 LocalDate date, final BigDecimal amount, final String reference, final long accountId,
final long transactionId) { final long transactionId) {
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId); journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
} }
@@ -258,9 +364,22 @@ public class SavingsPooledInterestPostingService {
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null); journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
} }
/**
* One journal entry leg.
*
* <p>
* Fineract convention: {@code type_enum} 2 = DEBIT, 1 = CREDIT — {@code JournalEntryType}
* declares {@code CREDIT(1)} first. This read {@code debit ? 1 : 2} and so signed every leg the
* wrong way round: the member's savings-control credit was stored as a debit and the expense
* debit as a credit. Nothing downstream caught it — the run's own pooled-equals-distributed check
* compares Java values and never looks at {@code type_enum}, and migration 0531 disables the
* stock posting job, so this is the only path writing savings interest to the ledger and there
* was no correct one alongside to disagree with it.
*/
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, 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 BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) { final Long transactionId) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
@@ -270,7 +389,7 @@ public class SavingsPooledInterestPostingService {
created_on_utc, last_modified_on_utc, submitted_on_date) created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount, """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
debit ? "Savings interest posting" : "Savings interest posted", debit ? "Savings interest posting" : "Savings interest posted",
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
systemUserId(), systemUserId(), date); systemUserId(), systemUserId(), date);
@@ -286,15 +405,88 @@ public class SavingsPooledInterestPostingService {
return ids.isEmpty() ? 1L : ids.get(0); 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); * The GL account the product maps under one of Fineract's fixed financial account types, as set
* on the product's Accounting tab.
*
* <p>
* A missing mapping is reported against the product rather than surfacing as an
* {@code EmptyResultDataAccessException} from inside a batch that has already posted other
* products — the message has to say which product to go and configure.
*/
private Long mappedGlAccount(final long productId, final int financialAccountType) {
final Long found = optionalMappedGlAccount(productId, financialAccountType);
if (found == null) {
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
}
return found;
} }
private Long mappedGlAccount(final long productId, final int financialAccountType) { /**
return jdbcTemplate.queryForObject(""" * One row of the product's Accounting tab, or null where the bank has not filled it in.
*
* <p>
* Separate from {@link #mappedGlAccount} because not every row is required: Interest Payable is
* offered on the product screen but a product configured before it existed has none, and that
* must fall through to the next choice rather than stopping the product from being paid.
*/
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl_account_id FROM acc_product_mapping SELECT gl_account_id FROM acc_product_mapping
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
LIMIT 1 LIMIT 1
""", Long.class, productId, financialAccountType); """, Long.class, productId, financialAccountType);
return found.isEmpty() ? null : found.get(0);
}
/**
* The GL account a product has mapped under one of the reserved names in its own GL mappings.
*
* <p>
* These live in {@code m_savings_product.other_gl_mappings} as a JSON document of
* {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in
* {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's
* fixed financial account types. The name is matched across all four groups: which group a bank
* files it under is a presentation choice, and a payable configured under the wrong heading
* should still be honoured rather than silently ignored.
*
* @return the account id, or null where the product does not configure this name
*/
private Long namedGlAccount(final long productId, final String accountName) {
// The column holds an empty string on a product saved with no named mappings, and casting
// that to jsonb is an error rather than an empty document — so the cast is done inside a
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
// clause in any order, so guarding in the same query level as the cast would only work by
// luck.
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE m.entry ->> 'accountName' = ?
LIMIT 1
""", Long.class, productId, accountName);
return found.isEmpty() ? null : found.get(0);
}
/** The first configured account of those given, in order of preference. */
private static Long firstNonNull(final Long... candidates) {
for (final Long candidate : candidates) {
if (candidate != null) {
return candidate;
}
}
return null;
} }
} }
@@ -0,0 +1,194 @@
/**
* 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.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;
/**
* Pushes a savings product's interest terms down onto the accounts opened under it.
*
* Fineract copies rate, posting period, compounding period, calculation type and days-in-year onto
* each account when it is created, and the interest engine reads that copy from then on. Editing the
* product changes nothing for accounts that already exist: a product moved from 7% to 10% goes on
* paying 7% on every account opened before the edit, indefinitely and silently. Because the copies
* are what the engine reads, they are also what the member is actually paid.
*
* This bank's rule is that interest follows the product, so an edit has to reach the accounts. The
* cascade runs on the same transaction as the product update, which means an edit either lands
* everywhere or nowhere - no state where the product says one thing and its accounts another.
*
* Scope is deliberately narrow:
*
* - savings only (deposit_type_enum 100). Fixed and recurring deposits keep the rate they were
* contracted at; that is the nature of a term deposit and changing it retroactively would
* rewrite an agreement.
* - active and pending accounts only. A closed account's terms are history.
* - min_balance_for_interest_calculation is never touched: it exists only on the account, so
* there is nothing on the product to cascade from.
*
* Interest already posted is not disturbed. Only future accrual moves, which is what an interest
* rate change ordinarily means.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class SavingsProductTermsCascadeService {
private static final int SAVINGS_DEPOSIT_TYPE = 100;
/** m_savings_account.status_enum: 100 submitted/pending approval, 200 approved, 300 active. */
private static final String CASCADEABLE_STATUSES = "(100, 200, 300)";
private final JdbcTemplate jdbcTemplate;
/**
* Copies the product's interest terms onto its savings accounts.
*
* @param productId
* the product that was edited
* @param changes
* what the update actually changed, from the command result; the cascade is skipped
* when none of the interest fields are among them
* @return how many accounts were updated
*/
@Transactional
public int cascadeInterestTerms(final Long productId, final Map<String, Object> changes) {
if (productId == null || changes == null || changes.isEmpty()) {
return 0;
}
if (!touchesInterestTerms(changes)) {
return 0;
}
final List<Map<String, Object>> product = jdbcTemplate.queryForList("""
SELECT nominal_annual_interest_rate, interest_compounding_period_enum,
interest_posting_period_enum, interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum
FROM m_savings_product
WHERE id = ? AND deposit_type_enum = ?
""", productId, SAVINGS_DEPOSIT_TYPE);
if (product.isEmpty()) {
// Not a savings product - a fixed or recurring deposit, which is left alone.
return 0;
}
// Only rows that actually differ are written, so the count reported is the number of
// accounts whose terms really moved rather than the size of the product's book.
final int updated = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate,
interest_compounding_period_enum = sp.interest_compounding_period_enum,
interest_posting_period_enum = sp.interest_posting_period_enum,
interest_calculation_type_enum = sp.interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum
FROM m_savings_product sp
WHERE sp.id = sa.product_id
AND sa.product_id = ?
AND sp.deposit_type_enum = ?
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
OR sa.interest_calculation_days_in_year_type_enum
<> sp.interest_calculation_days_in_year_type_enum)
""", productId, SAVINGS_DEPOSIT_TYPE);
if (updated > 0) {
log.info("Savings product {}: interest terms cascaded to {} account(s) — {}", productId, updated,
changes.keySet());
}
return updated;
}
/**
* Whether the edit touched anything the accounts hold a copy of.
*
* The names are the API's own parameter names, which is what the update result reports.
*/
private boolean touchesInterestTerms(final Map<String, Object> changes) {
return changes.containsKey("nominalAnnualInterestRate")
|| changes.containsKey("interestCompoundingPeriodType")
|| changes.containsKey("interestPostingPeriodType")
|| changes.containsKey("interestCalculationType")
|| changes.containsKey("interestCalculationDaysInYearType");
}
/**
* Brings every savings account in line with its product, regardless of what changed.
*
* For the accounts that drifted before the cascade existed - in this tenant, accounts opened on
* a 7% daily copy of a product that now reads 10% quarterly.
*
* @return how many accounts were updated
*/
@Transactional
public int realignAllAccounts() {
final int updated = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate,
interest_compounding_period_enum = sp.interest_compounding_period_enum,
interest_posting_period_enum = sp.interest_posting_period_enum,
interest_calculation_type_enum = sp.interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum
FROM m_savings_product sp
WHERE sp.id = sa.product_id
AND sp.deposit_type_enum = ?
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
OR sa.interest_calculation_days_in_year_type_enum
<> sp.interest_calculation_days_in_year_type_enum)
""", SAVINGS_DEPOSIT_TYPE);
log.info("Savings interest terms realigned to product on {} account(s)", updated);
return updated;
}
/** The accounts whose terms differ from their product, for a report or a pre-flight check. */
public List<Map<String, Object>> findDrifted() {
return jdbcTemplate.queryForList("""
SELECT sa.id AS account_id, sa.account_no, sp.id AS product_id, sp.name AS product_name,
sa.nominal_annual_interest_rate AS account_rate,
sp.nominal_annual_interest_rate AS product_rate,
sa.interest_posting_period_enum AS account_posting,
sp.interest_posting_period_enum AS product_posting,
sa.interest_calculation_type_enum AS account_calculation,
sp.interest_calculation_type_enum AS product_calculation
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 IN """ + CASCADEABLE_STATUSES + """
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
OR sa.interest_calculation_days_in_year_type_enum
<> sp.interest_calculation_days_in_year_type_enum)
ORDER BY sp.id, sa.id
""", SAVINGS_DEPOSIT_TYPE);
}
}
@@ -31,9 +31,11 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -53,8 +55,35 @@ public class SavingsDailyInterestApiResource {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService; private final SavingsDailyInterestService accrualService;
private final SavingsDailyInterestOrchestrator orchestrator; private final SavingsDailyInterestOrchestrator orchestrator;
private final SavingsProductTermsCascadeService termsCascadeService;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
/**
* The accounts whose interest terms differ from the product they were opened under.
*
* A product edit cascades to its accounts now, but accounts opened before that was so can still
* be sitting on the terms they were created with — and those copies are what the engine pays on.
*/
@GET
@Path("/drifted")
public String drifted() {
final List<Map<String, Object>> rows = termsCascadeService.findDrifted();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("count", rows.size());
result.put("accounts", rows);
return gson.toJson(result);
}
/** Brings every savings account's interest terms back in line with its product. */
@POST
@Path("/realign")
public String realign() {
final int updated = termsCascadeService.realignAllAccounts();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("accountsRealigned", updated);
return gson.toJson(result);
}
/** The savings products carrying a ledger, with where each one is in its timetable. */ /** The savings products carrying a ledger, with where each one is in its timetable. */
@GET @GET
@Path("/products") @Path("/products")
@@ -117,6 +146,119 @@ public class SavingsDailyInterestApiResource {
return gson.toJson(rows); return gson.toJson(rows);
} }
/**
* One row per account: where each one stands right now.
*
* <p>
* The ledger behind this is day-by-day and has to stay that way — interest is worked out on the
* balance each day carried, and a reversed backdated entry means every day after it is
* recomputed. What a reader wants, though, is the current position without reading thirty rows
* to find it, which is what {@code v_savings_interest_summary} gives: the latest day's figures
* per account, with the cycle total and the lifetime posted total beside them.
*
* <p>
* Optionally narrowed to one product, one office, or one account.
*/
@GET
@Path("/summary")
public String summary(@QueryParam("productId") final Long productId, @QueryParam("officeName") final String officeName,
@QueryParam("accountNo") final String accountNo) {
final List<Object> args = new java.util.ArrayList<>();
final StringBuilder sql = new StringBuilder("SELECT * FROM v_savings_interest_summary WHERE 1 = 1");
if (productId != null) {
sql.append(" AND savings_product_id = ?");
args.add(productId);
}
if (officeName != null && !officeName.isBlank()) {
sql.append(" AND office_name = ?");
args.add(officeName);
}
if (accountNo != null && !accountNo.isBlank()) {
sql.append(" AND account_no = ?");
args.add(accountNo);
}
sql.append(" ORDER BY account_no");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray()));
}
/**
* Interest actually paid to members, one row per posting.
*
* <p>
* Read from the savings transactions rather than the daily ledger. The ledger holds one row per
* account carrying the current cycle, and clears the posted amount when the next cycle opens, so
* it cannot answer what was paid last quarter. The transaction is the payment itself and stays.
*
* <p>
* {@code period} narrows to one posting rhythm, matching the product's own
* {@code interest_posting_period_enum}: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly,
* 7 annual. A report screen shows each rhythm on its own, because a monthly product's
* month-end posting and a quarterly product's quarter-end posting are different events and
* mixing them reads as one uneven series.
*/
@GET
@Path("/postings")
public String postings(@QueryParam("period") final Integer period, @QueryParam("productId") final Long productId,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("officeName") final String officeName) {
final List<Object> args = new java.util.ArrayList<>();
final StringBuilder sql = new StringBuilder("""
SELECT t.id AS transaction_id,
t.savings_account_id AS savings_account_id,
sa.account_no AS account_no,
sa.client_id AS client_id,
c.display_name AS client_name,
o.name AS office_name,
sa.product_id AS savings_product_id,
sp.name AS product_name,
sp.interest_posting_period_enum AS posting_period_enum,
sp.nominal_annual_interest_rate AS interest_rate,
sa.currency_code AS currency_code,
-- Formatted here rather than left as a date: this resource serialises with a
-- plain Gson, which renders a java.sql.Date as an object the browser cannot
-- read back, and every posting showed as "Invalid Date".
TO_CHAR(t.transaction_date, 'YYYY-MM-DD') AS posting_date,
t.amount AS interest_amount,
t.running_balance_derived AS balance_after,
(t.running_balance_derived - t.amount) AS balance_before,
t.created_on_utc AS recorded_on
FROM m_savings_account_transaction t
JOIN m_savings_account sa ON sa.id = t.savings_account_id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
WHERE t.transaction_type_enum = 3
AND t.is_reversed = false
AND sp.deposit_type_enum = 100
""");
if (period != null) {
sql.append(" AND sp.interest_posting_period_enum = ? ");
args.add(period);
}
if (productId != null) {
sql.append(" AND sa.product_id = ? ");
args.add(productId);
}
if (fromDate != null && !fromDate.isBlank()) {
sql.append(" AND t.transaction_date >= ? ");
args.add(LocalDate.parse(fromDate));
}
if (toDate != null && !toDate.isBlank()) {
sql.append(" AND t.transaction_date <= ? ");
args.add(LocalDate.parse(toDate));
}
if (officeName != null && !officeName.isBlank()) {
sql.append(" AND o.name = ? ");
args.add(officeName);
}
sql.append(" ORDER BY t.transaction_date DESC, sa.account_no");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray()));
}
/** What each posting run pooled and paid, and whether it finished. */ /** What each posting run pooled and paid, and whether it finished. */
@GET @GET
@Path("/runs") @Path("/runs")
@@ -149,16 +291,33 @@ public class SavingsDailyInterestApiResource {
return gson.toJson(result); return gson.toJson(result);
} }
/** Runs a day by hand, for a first backfill or after fixing a failed night. */ /**
* Runs a day by hand, for a first backfill or after fixing a failed night.
*
* Defaults to the business date, and refuses to run past it. Accruing or posting for a date the
* bank has not reached writes interest into the books for days that have not happened: the
* member's balance goes up today for a quarter that ends next month, and the journal entries
* carry that future date. Worse, the business date can be moved back afterwards — for a test, or
* because it was advanced by mistake — and then the ledger holds a posting dated beyond the day
* the bank is working, which nothing reconciles against.
*
* To post for a later date, move the business date there first. That is the same order the
* nightly run follows, and it keeps the books and the business date in step.
*/
@POST @POST
@Path("/run") @Path("/run")
public String run(@QueryParam("date") final String dateParam) { public String run(@QueryParam("date") final String dateParam) {
final LocalDate businessDate = dateParam == null || dateParam.isBlank() final LocalDate businessDate = DateUtils.getBusinessLocalDate();
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) final LocalDate runFor = dateParam == null || dateParam.isBlank() ? businessDate : LocalDate.parse(dateParam);
: LocalDate.parse(dateParam); if (runFor.isAfter(businessDate)) {
orchestrator.runForDate(businessDate); throw new PlatformDataIntegrityException("error.msg.savings.interest.run.after.business.date",
"Cannot run savings interest for " + runFor + ": the business date is " + businessDate
+ ". Move the business date forward first — interest cannot be accrued or posted for a day"
+ " the bank has not reached.");
}
orchestrator.runForDate(runFor);
final Map<String, Object> result = new LinkedHashMap<>(); final Map<String, Object> result = new LinkedHashMap<>();
result.put("ranFor", businessDate.toString()); result.put("ranFor", runFor.toString());
return gson.toJson(result); return gson.toJson(result);
} }
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
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 SavingsDailyInterestConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step savingsDailyInterestStep(SavingsDailyInterestTasklet savingsDailyInterestTasklet) {
return new StepBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository)
.tasklet(savingsDailyInterestTasklet, transactionManager).build();
}
@Bean
public Job savingsDailyInterestJob(SavingsDailyInterestTasklet savingsDailyInterestTasklet) {
return new JobBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository)
.start(savingsDailyInterestStep(savingsDailyInterestTasklet)).incrementer(new RunIdIncrementer()).build();
}
}

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