Compare commits

..
13 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
139 changed files with 8774 additions and 622 deletions
@@ -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();
} }
@@ -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";
@@ -56,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) {
@@ -562,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);
@@ -620,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)
@@ -670,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);
@@ -709,21 +758,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.expiryDate; return this.expiryDate;
} }
this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears()); this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
return this.expiryDate; 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);
@@ -749,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;
} }
@@ -791,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;
@@ -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;
} }
@@ -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);
@@ -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;
@@ -172,6 +173,15 @@ public class SecurityConfig {
// IP allowlist) — a request with no key and no Fineract login still gets nothing, // 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. // it just fails inside the handler instead of at this filter.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll() .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()
@@ -417,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(),
@@ -454,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,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);
}
}
@@ -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"));
} }
@@ -875,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];
@@ -17,6 +17,7 @@ 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;
@@ -37,7 +38,7 @@ public class CashReportApiResource {
private final Gson gson = new Gson(); private final Gson gson = new Gson();
/** /**
* Excludes reversed counter transactions from a cash figure. * 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 * 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 * counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
@@ -46,13 +47,47 @@ public class CashReportApiResource {
* its own reversal flag; this is the same guard for the counter, read off the transaction the * its own reversal flag; this is the same guard for the counter, read off the transaction the
* breakdown belongs to. * breakdown belongs to.
* *
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD * <p>
* register keys its rows by its own request id, not a savings transaction -- is kept rather than * Two things are excluded, and they are different failures:
* silently dropped. *
* <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 (" private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt " + " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "; + " 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.
@@ -150,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,
@@ -210,6 +250,94 @@ public class CashReportApiResource {
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE) AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate)); """, 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();
@@ -218,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"))) {
@@ -236,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"))) {
@@ -247,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) -> {
@@ -272,9 +418,28 @@ public class CashReportApiResource {
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"));
@@ -282,6 +447,12 @@ public class CashReportApiResource {
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says // 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. // where the entry came from, not who typed it.
row.put("section", rs.getString("section")); 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"));
@@ -334,39 +505,93 @@ public class CashReportApiResource {
+ " 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 = ?) " : "";
// Single query — conditional aggregation for deposit, withdrawal, net per denomination // Every note and coin that reached the drawer or left it, per denomination.
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, " //
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, " // Four legs, not one. The counter breakdown alone answered "what did customers hand over",
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, " // which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, " // ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " // the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
+ "FROM m_savings_txn_denomination std " // counted against. So the vault allocation and the settlement back are included on the same
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " // footing as the counter work, and each row says where its notes came from.
+ "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED //
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind " // Cashier scoping differs per leg because each table records the party differently: the
+ "ORDER BY std.denomination_value DESC, dm.kind"; // 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<>(); final List<Object> args = new ArrayList<>();
args.add(txnDate); args.add(txnDate);
if (scopedCashierId != null) { if (scopedCashierId != null) {
args.add(scopedCashierId); 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;
}, args.toArray()); }, args.toArray());
@@ -488,10 +713,49 @@ public class CashReportApiResource {
result.put("openingBalance", openingBalance); result.put("openingBalance", openingBalance);
result.put("closingBalance", closingBalance); result.put("closingBalance", closingBalance);
result.put("cashMovement", closingBalance.subtract(openingBalance)); 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. * What one cashier is holding at the end of a given date, in rupees.
* *
@@ -826,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 "
@@ -1065,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
@@ -1100,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()) {
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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;
@@ -57,9 +58,21 @@ public class CashSettlementApiResource {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper; private final CashierRoleHelper cashierRoleHelper;
private final VoucherService voucherService; 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");
@@ -112,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();
@@ -290,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");
@@ -25,13 +25,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal; import jakarta.ws.rs.core.Response;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.LocalDate; import java.time.LocalDate;
@@ -47,28 +47,44 @@ import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredential
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData; import org.springframework.dao.EmptyResultDataAccessException;
import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* A single client's full deposit + share picture, looked up by client number OR mobile number (the * A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share
* path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) rather * accounts are out of scope for this endpoint, see below), looked up by client number OR mobile number
* than the internal client id -- these are what a counter clerk has on the slip, or what a third-party * (the path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no})
* caller was given, respectively, so no separate client-search step is needed before this call. Built * rather than the internal client id -- these are what a counter clerk has on the slip, or what a
* so the whole thing, including the client lookup itself, resolves in a small handful of index-scan-only * third-party caller was given, respectively, so no separate client-search step is needed before this
* queries, on the theory that this is exactly the kind of check a teller or manager runs right before * call. Built so the whole thing, including the client lookup itself, resolves in a small handful of
* approving something (a share purchase, a closure, a large withdrawal) -- the "is there a problem here" * index-scan-only queries.
* question this endpoint answers has to come back before that decision is made, not after. *
* 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} * 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 * (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 * m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
* caller doesn't need -- account number, product, status and the current balance/share value is the * caller doesn't need. Fewer joins and fewer columns is what actually makes this fast, not a different
* whole ask. Fewer joins and fewer columns is what actually makes this fast, not a different query * query engine.
* engine.
* *
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as * 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 * they do every other endpoint; a third-party integration that should never see a Fineract username or
@@ -80,7 +96,7 @@ import org.springframework.stereotype.Component;
*/ */
@Path("/v1/clientaccounts") @Path("/v1/clientaccounts")
@Component @Component
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of every Savings/Fixed Deposit/Recurring Deposit/Current/Share account a client holds, by client number or mobile number.") @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 @RequiredArgsConstructor
public class ClientAllAccountsApiResource { public class ClientAllAccountsApiResource {
@@ -95,44 +111,56 @@ public class ClientAllAccountsApiResource {
// that unrelated feature toggle is on. // that unrelated feature toggle is on.
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" }; private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
// One UNION ALL, one round trip: SB/FD/RD (m_savings_account, split by deposit_type_enum), // ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses
// CD (m_current_account, its own table -- current accounts were never folded into // the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the
// m_savings_account here, see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the same // same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD
// split), and Share (m_share_account). Every leg filters on client_id, which is indexed on // (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table --
// all three tables (fksa00000000000001, idx_ca_client_id, m_share_account_ibfk_9) -- an // 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. // index-only lookup per leg, not a scan.
private static final String ACCOUNTS_SQL = """ 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, 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, sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
p.name AS product_name, p.short_name AS short_product_name, p.name AS product_name, p.short_name AS short_product_name,
sa.account_balance_derived AS balance, COALESCE(sa.on_hold_funds_derived, 0) AS on_hold,
NULL::bigint AS approved_shares, NULL::bigint AS pending_shares, NULL::numeric(10,2) AS unit_price,
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
FROM m_savings_account sa FROM m_savings_account sa
JOIN m_savings_product p ON p.id = sa.product_id JOIN m_savings_product p ON p.id = sa.product_id
WHERE sa.client_id = ? WHERE sa.client_id = ? AND sa.status_enum = 300
UNION ALL UNION ALL
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id, 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.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
ca.account_balance_derived, COALESCE(ca.on_hold_funds_derived, 0),
NULL::bigint, NULL::bigint, NULL::numeric(10,2),
ca.submittedon_date, ca.activatedon_date ca.submittedon_date, ca.activatedon_date
FROM m_current_account ca FROM m_current_account ca
JOIN m_current_product cp ON cp.id = ca.product_id JOIN m_current_product cp ON cp.id = ca.product_id
WHERE ca.client_id = ? WHERE ca.client_id = ? AND ca.status_enum = 300
""";
UNION ALL // 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 = ?
""";
SELECT CAST(NULL AS smallint) AS deposit_type, shr.id, shr.account_no, shr.external_id, // The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row,
shr.status_enum, shr.currency_code, shr.product_id, sp.name, sp.short_name, // id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a
NULL::numeric(19,6), NULL::numeric(19,6), // payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's
shr.total_approved_shares, shr.total_pending_shares, sp.unit_price, // IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative
shr.submitted_date, shr.activated_date // block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and
FROM m_share_account shr // the created/updated audit columns, which are an internal concern of the Bank Profile screen, not
JOIN m_share_product sp ON sp.id = shr.product_id // this endpoint's caller.
WHERE shr.client_id = ? 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 @GET
@@ -156,22 +184,18 @@ public class ClientAllAccountsApiResource {
final Map<String, Object> client = resolveClient(identifier, tenant); final Map<String, Object> client = resolveClient(identifier, tenant);
final Long clientId = ((Number) client.get("id")).longValue(); final Long clientId = ((Number) client.get("id")).longValue();
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId); final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId);
final Map<String, Object> totalsByCurrency = new LinkedHashMap<>();
final Map<String, Integer> countByType = new LinkedHashMap<>(); final Map<String, Integer> countByType = new LinkedHashMap<>();
for (final Map<String, Object> row : rows) { for (final Map<String, Object> row : rows) {
final String accountType = (String) row.get("accountType"); final String accountType = (String) row.get("accountType");
countByType.merge(accountType, 1, Integer::sum); countByType.merge(accountType, 1, Integer::sum);
final Object valueObj = row.get("currentValue");
if (valueObj instanceof BigDecimal value) {
final String currency = (String) row.get("currencyCode");
final BigDecimal running = (BigDecimal) totalsByCurrency.getOrDefault(currency, BigDecimal.ZERO);
totalsByCurrency.put(currency, running.add(value));
}
} }
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(); final JsonObject res = new JsonObject();
// Which bank this response came from -- not decorative. One API key now authenticates // Which bank this response came from -- not decorative. One API key now authenticates
// against every tenant (see ApiClientCredentialService), so the ONLY thing that decides // against every tenant (see ApiClientCredentialService), so the ONLY thing that decides
@@ -187,15 +211,71 @@ public class ClientAllAccountsApiResource {
res.addProperty("clientNo", (String) client.get("account_no")); res.addProperty("clientNo", (String) client.get("account_no"));
res.addProperty("clientName", (String) client.get("display_name")); res.addProperty("clientName", (String) client.get("display_name"));
res.addProperty("mobileNo", (String) client.get("mobile_no")); res.addProperty("mobileNo", (String) client.get("mobile_no"));
res.addProperty("officeId", ((Number) client.get("office_id")).longValue()); res.addProperty("officeId", officeId);
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue()); 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.addProperty("totalAccounts", rows.size());
res.add("accountsByType", gson.toJsonTree(countByType)); res.add("accountsByType", gson.toJsonTree(countByType));
res.add("totalCurrentValueByCurrency", gson.toJsonTree(totalsByCurrency));
res.add("accounts", gson.toJsonTree(rows)); res.add("accounts", gson.toJsonTree(rows));
return gson.toJson(res); 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 * 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 * the worst case, one in the common case, both on unique indexed columns
@@ -234,11 +314,33 @@ public class ClientAllAccountsApiResource {
return client; return client;
} }
throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '" 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 " + tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank."); + "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) { private String resolveClientIp(final HttpServletRequest request) {
for (final String header : IP_HEADER_CANDIDATES) { for (final String header : IP_HEADER_CANDIDATES) {
final String value = request.getHeader(header); final String value = request.getHeader(header);
@@ -252,27 +354,19 @@ public class ClientAllAccountsApiResource {
} }
/** /**
* Maps one UNION leg row into a flat, display-ready shape -- category label, human status, and a * Maps one UNION leg row into a flat, display-ready shape -- category label and human status only.
* single {@code currentValue} figure so the caller never has to know that a deposit's value lives * No balance figure of any kind: this is an account listing, not a balance enquiry (see the class
* in one column and a share account's lives in another (approved shares × unit price). * Javadoc).
*/ */
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> { private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
@Override @Override
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException { public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Integer depositType = JdbcSupport.getInteger(rs, "deposit_type"); final int depositType = rs.getInt("deposit_type");
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum"); final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
final BigDecimal balance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "balance");
final BigDecimal onHold = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold");
final Long approvedShares = JdbcSupport.getLong(rs, "approved_shares");
final Long pendingShares = JdbcSupport.getLong(rs, "pending_shares");
final BigDecimal unitPrice = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "unit_price");
final boolean isShare = depositType == null;
final String accountType = isShare ? "Share Account" : accountTypeFor(depositType);
final Map<String, Object> row = new LinkedHashMap<>(); final Map<String, Object> row = new LinkedHashMap<>();
row.put("accountType", accountType); row.put("accountType", accountTypeFor(depositType));
row.put("accountId", rs.getLong("account_id")); row.put("accountId", rs.getLong("account_id"));
row.put("accountNo", rs.getString("account_no")); row.put("accountNo", rs.getString("account_no"));
row.put("externalId", rs.getString("external_id")); row.put("externalId", rs.getString("external_id"));
@@ -288,28 +382,8 @@ public class ClientAllAccountsApiResource {
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date"))); row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date"))); row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
if (isShare) { final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum); row.put("status", status);
row.put("status", status);
row.put("approvedShares", approvedShares);
row.put("pendingShares", pendingShares);
row.put("unitPrice", unitPrice);
final BigDecimal shareValue = (approvedShares != null && unitPrice != null)
? unitPrice.multiply(BigDecimal.valueOf(approvedShares))
: null;
row.put("currentValue", shareValue);
} else {
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
row.put("status", status);
row.put("balance", balance);
row.put("onHoldFunds", onHold);
BigDecimal available = balance;
if (available != null && onHold != null) {
available = available.subtract(onHold);
}
row.put("availableBalance", available);
row.put("currentValue", balance);
}
return row; return row;
} }
@@ -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;
@@ -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,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"));
@@ -48,8 +48,19 @@ 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(); enrolNewProducts();
@@ -61,33 +72,92 @@ 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");
}
} }
/** /**
@@ -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;
@@ -194,11 +195,19 @@ public class SavingsDailyInterestService {
// today's figures. Only a row for a day before today can be carried forward — one already // 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" // 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. // 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
""", accountId, businessDate); """, accountId, businessDate);
final BigDecimal openingBalance; final BigDecimal openingBalance;
@@ -208,8 +217,6 @@ 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;
// The first row belongs to the cycle the day it records falls in, and starts at // The first row belongs to the cycle the day it records falls in, and starts at
// activation only when that day is still inside the account's opening cycle. Anchoring // activation only when that day is still inside the account's opening cycle. Anchoring
@@ -221,6 +228,19 @@ public class SavingsDailyInterestService {
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod, final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
financialYearBeginningMonth); financialYearBeginningMonth);
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart; 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");
@@ -237,10 +257,31 @@ public class SavingsDailyInterestService {
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, 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");
}
} }
} }
@@ -249,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;
@@ -389,6 +429,166 @@ public class SavingsDailyInterestService {
* It cannot double-count, because a posting only ever falls on one day and the row for that day * 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). * 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
@@ -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,24 +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 the liability the product names as its Savings Interest * This settles the debt the accrual has already recognised: the product's total moves out of the
* Payable, and only then pays it out to members: * liability the product names as its Savings Interest Payable, into the account it is paid out of,
* and only then to the members:
* *
* <pre> * <pre>
* Dr Savings Interest Paid / Interest On Savings (expense, from the product) * Dr Savings Interest Payable (liability, from the product)
* Cr Savings Interest Payable (liability, from the product) * Cr Savings Interest Paid on its way out
* *
* Dr Savings Interest Payable (per member) * Dr Savings Interest Paid (per member)
* Cr Savings Control (per member, from the product) * Cr Savings Control (per member, from the product) in the member's hands
* </pre> * </pre>
* *
* Every account named above is read from the product's own GL configuration; none is written into * The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
* this class. A product that configures no payable falls back to the two seeded control accounts, * this is the settlement of what that recognition already booked - raising the expense again at
* which keeps products predating that mapping posting exactly as before. * payout would put the same interest on the P&amp;L twice.
* *
* The whole product settles inside one transaction, and the payable has to read zero before it is * Every account named above is read from the product's own GL configuration; none is written into
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run * this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
* that never reached commit leaves a PENDING row naming the product and date for the next run. * 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
@@ -75,34 +92,45 @@ public class SavingsPooledInterestPostingService {
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
/** /**
* Reserved names in the product's own GL mappings, which is where the two legs of the interest * Reserved names in the product's own GL mappings, which is where the middle two legs of the
* bill are configured. The names are the contract between this service and the product screen — * interest bill are configured. The names are the contract between this service and the product
* the GL codes behind them are the bank's choice, not ours, so nothing here names an account. * 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_PAYABLE = "Savings Interest Payable";
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid"; private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
/** Fallback control accounts, used only where the product leaves the mappings unset. */
private static final String GL_POSTING_CONTROL = "200304";
private static final String GL_POSTED_CONTROL = "200305";
/** 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
@@ -113,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.
@@ -127,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
@@ -164,36 +202,55 @@ public class SavingsPooledInterestPostingService {
largest.get("savings_account_id")); largest.get("savings_account_id"));
} }
// The expense the bill is recognised against, and the liability it sits in until it reaches // The three accounts the bill passes through on its way to the members. Every one is read
// the members. Both come from the product: "Savings Interest Paid" if the product names one, // from the product's own configuration, so a bank on its own chart of accounts is posted to
// otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the // the accounts it chose. No GL code is named in this class.
// liability, falling back to the control accounts only where a product configures neither. //
final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), // The product's Accounting tab is the authority, and it is consulted first for all three:
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS)); // Interest On Savings for the expense, and the standard Interest Payable row for the
final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE); // payable. Those are the rows a bank actually fills in on the product screen.
final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL)); //
final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL); // 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 parked in the payable. // 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.
// Stage 2: the pool moves to the account it is paid out of.
// //
// Where the product names a single payable, both control legs resolve to it and this stage // The interest expense itself is not raised here. It is recognised as the interest accrues,
// would debit and credit the same account for the same amount — a pair of entries that say // day by day, and posting is the settlement of that recognised debt — raising the expense
// nothing and clutter the ledger. Only a product still on the two distinct control accounts // again at payout would count the same interest twice on the P&L.
// needs the transfer written. //
if (!postingControlGl.equals(postedControlGl)) { // Skipped where the product names no separate paid account: both legs then resolve to the
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); // payable and this would debit and credit one account for the same amount, saying nothing.
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); 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 3: out to the members, one at a time. // Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
// 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 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");
@@ -208,7 +265,7 @@ 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);
@@ -223,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);
} }
@@ -288,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);
} }
@@ -297,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,
@@ -309,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);
@@ -325,16 +405,39 @@ 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);
} }
/** /**
@@ -377,7 +480,13 @@ public class SavingsPooledInterestPostingService {
return found.isEmpty() ? null : found.get(0); return found.isEmpty() ? null : found.get(0);
} }
private static Long firstNonNull(final Long preferred, final Long fallback) { /** The first configured account of those given, in order of preference. */
return preferred != null ? preferred : fallback; private static Long firstNonNull(final Long... candidates) {
for (final Long candidate : candidates) {
if (candidate != null) {
return candidate;
}
}
return null;
} }
} }
@@ -31,6 +31,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.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;
@@ -290,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);
} }
@@ -43,6 +43,9 @@ import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -59,6 +62,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
private final ConfigurationDomainService configurationDomainService; private final ConfigurationDomainService configurationDomainService;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final BusinessEventNotifierService businessEventNotifierService; private final BusinessEventNotifierService businessEventNotifierService;
private final KycRestrictionGuard kycRestrictionGuard;
private final SavingsDailyInterestService savingsDailyInterestService;
private final VoucherService voucherService;
@Autowired @Autowired
public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository, public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository,
@@ -67,7 +73,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final JournalEntryWritePlatformService journalEntryWritePlatformService, final JournalEntryWritePlatformService journalEntryWritePlatformService,
final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context, final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context,
final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
final BusinessEventNotifierService businessEventNotifierService) { final BusinessEventNotifierService businessEventNotifierService,
final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService,
final KycRestrictionGuard kycRestrictionGuard) {
this.savingsAccountRepository = savingsAccountRepository; this.savingsAccountRepository = savingsAccountRepository;
this.savingsAccountTransactionRepository = savingsAccountTransactionRepository; this.savingsAccountTransactionRepository = savingsAccountTransactionRepository;
this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper; this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper;
@@ -76,6 +84,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
this.context = context; this.context = context;
this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository; this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository;
this.businessEventNotifierService = businessEventNotifierService; this.businessEventNotifierService = businessEventNotifierService;
this.savingsDailyInterestService = savingsDailyInterestService;
this.voucherService = voucherService;
this.kycRestrictionGuard = kycRestrictionGuard;
} }
@Transactional @Transactional
@@ -86,6 +97,10 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
context.authenticatedUser(); context.authenticatedUser();
account.validateForAccountBlock(); account.validateForAccountBlock();
account.validateForDebitBlock(); account.validateForDebitBlock();
// Every withdrawal in the system arrives here — the clerk's counter, the core API, an
// account transfer, a standing instruction — so the dormancy and re-KYC ladders are
// enforced at this one point rather than at each of those doors.
kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.DEBIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd(); .isSavingsInterestPostingAtCurrentPeriodEnd();
final Long relaxingDaysConfigForPivotDate = this.configurationDomainService.retrieveRelaxingDaysConfigForPivotDate(); final Long relaxingDaysConfigForPivotDate = this.configurationDomainService.retrieveRelaxingDaysConfigForPivotDate();
@@ -148,6 +163,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill); transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill);
} }
// As on the deposit path: the ledger's row must follow the balance out, or the account goes on
// earning on money it no longer holds until tonight's run corrects it.
savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today);
// The debit voucher for the withdrawal, including any withdrawal fee the posting collected —
// one slip, every leg, the way a payment voucher is written at a counter.
if (!transactionBooleanValues.isSkipAccountingPostings()) {
voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_SAVINGS_TRANSACTION,
withdrawal.getId(), context.authenticatedUser(), VoucherService.PREFIX_DEBIT);
}
businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
return withdrawal; return withdrawal;
} }
@@ -188,6 +214,12 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
context.authenticatedUser(); context.authenticatedUser();
account.validateForAccountBlock(); account.validateForAccountBlock();
account.validateForCreditBlock(); account.validateForCreditBlock();
// Money coming in is a credit however it arrives — over the counter, or transferred from
// another account. The restriction is on the customer taking money out or moving it away,
// not on anyone paying them: a salary credit, a benefit transfer or a relative sending
// funds must land while the customer is being chased for their re-KYC, and it cannot be
// used to spirit the balance anywhere, since the debit side stays shut.
kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.CREDIT);
// Global configurations // Global configurations
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
@@ -238,6 +270,21 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
if (!skipAccountingPostings) { if (!skipAccountingPostings) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill);
} }
// The daily-interest ledger states the balance as at its last row, which is yesterday's until
// tonight's run. A closure raised in between reads that row, so money paid in today would earn
// nothing and money paid out would go on earning. Bringing the row up to date here keeps what
// a closure reads in step with what the account holds.
savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today);
// The credit voucher for the deposit. Raised from the journal entries the posting just wrote,
// so a deposit that also collected a charge carries every leg on ONE slip — which is what the
// member signs and the branch files. This is the path every counter deposit takes (cashier,
// clerk, transfer in), and it raised no voucher at all before.
if (!skipAccountingPostings) {
voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_DEPOSIT, VoucherService.REF_SAVINGS_TRANSACTION,
deposit.getId(), context.authenticatedUser(), VoucherService.PREFIX_CREDIT);
}
businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
return deposit; return deposit;
} }
@@ -0,0 +1,72 @@
/**
* 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.service;
/**
* The one definition of when a savings account was last known to be in live use, for the dormancy
* ladder to measure from.
*
* <p>
* It is written here once because it is read from six places — the nightly job's three selection
* queries, the account read used by the counter screen, the account list, and the dormancy report
* — and those places disagreeing is what makes an account inactive on one screen and active on the
* next. A change to the rule belongs in this file and nowhere else.
*
* <h3>What counts</h3>
*
* <p>
* A completed re-KYC, and nothing else. RBI's Master Direction on KYC makes periodic re-verification
* the thing that keeps an account operative, and a lapsed account operable again only once the
* customer's identity has been established afresh; a cash deposit at the counter is not that
* verification, so it no longer revives an account here.
*
* <p>
* The account's activation stands in for a client who has never been re-verified, so a newly opened
* account does not start life a year behind.
*/
public final class DormancyClock {
private DormancyClock() {}
/**
* SQL for the date the dormancy ladder measures from, as an expression over the aliases
* {@code sa} (m_savings_account) and {@code c} (m_client).
*
* <p>
* Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a
* WHERE clause without having to add a join it may already have — and so a client with no KYC
* record at all falls back to the activation date rather than dropping out of the result.
*
* <p>
* Null before the account is ever activated: {@code GREATEST} in Postgres ignores a null
* argument rather than propagating it, so without this guard an account still awaiting approval
* (activation date not yet set) would report the client's own KYC re-verification date as its
* "last live" date — a date that has nothing to do with this account, which has never been live
* at all.
*/
public static final String LAST_LIVE_ON = """
CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE
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))
END
""";
}
@@ -63,9 +63,7 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) { public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
this.context.authenticatedUser(); this.context.authenticatedUser();
final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) " final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON;
+ "from m_savings_account_transaction sat where sat.savings_account_id = sa.id "
+ "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))";
final StringBuilder sql = new StringBuilder(); final StringBuilder sql = new StringBuilder();
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, "); sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
@@ -85,19 +83,57 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
// Left join: an escheated account has a remittance row only once the money has gone to RBI, // Left join: an escheated account has a remittance row only once the money has gone to RBI,
// and the absence of one is exactly what the report has to show. // and the absence of one is exactly what the report has to show.
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id "); sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
// Only the three states the ladder sets. A blocked or a plainly active account is not // An account belongs on these reports once the ladder has stamped it, or once the days
// dormant and does not belong on these reports. // have earned it — whichever comes first.
sql.append("where sa.sub_status_enum in (?, ?, ?) "); //
// The stamp is applied by the nightly job, so an account that crossed a threshold today
// still carries sub_status 0 until that job next runs, and the report showed nothing at
// all: an account 23 days past its dormancy date was absent from a report whose whole
// purpose is to list it. The elapsed count is already computed above for the daysIdle
// column and uses the job's own definition of a customer transaction, so reading the
// thresholds off the same product row here cannot disagree with what the job will do.
//
// Escheat is matched on the stamp alone. Unlike the other two it is not a label but a
// movement of money — the job closes the account and takes the balance out — and listing
// an account as escheated before that happened would report money as remitted that is
// still sitting in the account.
sql.append("where (sa.sub_status_enum in (?, ?, ?) ");
sql.append(" or (sp.is_dormancy_tracking_active = true and sa.status_enum = 300 ");
sql.append(" and (sp.days_to_inactive > 0 and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
.append(" >= sp.days_to_inactive))) ");
final List<Object> params = new ArrayList<>(); final List<Object> params = new ArrayList<>();
params.add(DateUtils.getBusinessLocalDate()); params.add(DateUtils.getBusinessLocalDate());
params.add(SUB_STATUS_INACTIVE); params.add(SUB_STATUS_INACTIVE);
params.add(SUB_STATUS_DORMANT); params.add(SUB_STATUS_DORMANT);
params.add(SUB_STATUS_ESCHEAT); params.add(SUB_STATUS_ESCHEAT);
params.add(DateUtils.getBusinessLocalDate());
if (subStatus != null) { if (subStatus != null) {
sql.append("and sa.sub_status_enum = ? "); // Match the stamp or the stage the elapsed days have earned, so a stage filter picks up
params.add(subStatus); // the same accounts the unfiltered report shows.
if (subStatus == SUB_STATUS_ESCHEAT) {
sql.append("and sa.sub_status_enum = ? ");
params.add(subStatus);
} else if (subStatus == SUB_STATUS_DORMANT) {
// Past the escheat threshold but not yet escheated still counts as dormant: the
// account is dormant until the job closes it and moves the money, and it has to
// appear somewhere until then.
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_dormancy > 0 and ")
.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" >= sp.days_to_dormancy)) ");
params.add(subStatus);
params.add(SUB_STATUS_ESCHEAT);
params.add(DateUtils.getBusinessLocalDate());
} else {
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_inactive > 0 and ")
.append(sqlGenerator.dateDiff("?", lastCustomerTxn))
.append(" >= sp.days_to_inactive and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
.append(" < sp.days_to_dormancy)) ");
params.add(subStatus);
params.add(SUB_STATUS_DORMANT);
params.add(DateUtils.getBusinessLocalDate());
params.add(DateUtils.getBusinessLocalDate());
}
} }
if (officeName != null && !officeName.isBlank()) { if (officeName != null && !officeName.isBlank()) {
sql.append("and o.name = ? "); sql.append("and o.name = ? ");
@@ -114,9 +150,19 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
@Override @Override
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException { public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final int subStatus = rs.getInt("subStatus");
final BigDecimal balance = rs.getBigDecimal("balance"); final BigDecimal balance = rs.getBigDecimal("balance");
final long daysIdle = rs.getLong("daysIdle"); final long daysIdle = rs.getLong("daysIdle");
// The stage the elapsed days put the account at, in place of the stamp — the same rule
// the WHERE clause selects on, so a row cannot appear on the report under one stage and
// be labelled another.
//
// The days replace the stamp rather than merely raising it, because the stamp can also
// be ahead: a customer transaction resets the clock, and an escheat that has not
// happened yet leaves an account reported as dormant, which is what it still is.
// Escheat itself is the exception and keeps its stamp, since the job has by then closed
// the account and moved the balance out.
final int stamped = rs.getInt("subStatus");
final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle);
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo")) return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName")) .clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
@@ -134,6 +180,27 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build(); .deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
} }
/**
* The stage the elapsed days have earned, whatever the job has stamped so far.
*
* <p>
* Escheat is never earned this way. Unlike the other two it is not a label but a movement
* of money — the job closes the account and takes the balance out — so an account only
* counts as escheated once that has actually happened, and until then it is reported as
* dormant, which is what it still is.
*/
private int earnedStage(final ResultSet rs, final long daysIdle) throws SQLException {
final Long toDormancy = nullableLong(rs, "daysToDormancy");
if (toDormancy != null && toDormancy > 0 && daysIdle >= toDormancy) {
return SUB_STATUS_DORMANT;
}
final Long toInactive = nullableLong(rs, "daysToInactive");
if (toInactive != null && toInactive > 0 && daysIdle >= toInactive) {
return SUB_STATUS_INACTIVE;
}
return 0;
}
private Long nullableLong(final ResultSet rs, final String column) throws SQLException { private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
final long value = rs.getLong(column); final long value = rs.getLong(column);
return rs.wasNull() ? null : value; return rs.wasNull() ? null : value;
@@ -22,9 +22,11 @@ import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsCons
import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD; import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD;
import java.math.BigDecimal; import java.math.BigDecimal;
import lombok.extern.slf4j.Slf4j;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
@@ -66,6 +68,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData; import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
import org.apache.fineract.portfolio.savings.data.SavingsProductData; import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData; import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData;
@@ -83,6 +86,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
@Slf4j
public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService { public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
@@ -104,14 +108,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
private final SavingsAccountAssembler savingAccountAssembler; private final SavingsAccountAssembler savingAccountAssembler;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
private final SavingsDailyInterestService savingsDailyInterestService;
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
final SavingsDailyInterestService savingsDailyInterestService) {
this.context = context; this.context = context;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.sqlGenerator = sqlGenerator; this.sqlGenerator = sqlGenerator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
this.savingsDailyInterestService = savingsDailyInterestService;
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
this.transactionsMapper = new SavingsAccountTransactionsMapper(); this.transactionsMapper = new SavingsAccountTransactionsMapper();
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
@@ -122,6 +129,98 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
this.savingAccountAssembler = savingAccountAssembler; this.savingAccountAssembler = savingAccountAssembler;
} }
/**
* Re-accrues one account's interest ledger if it is not already at the business date.
*
* <p>
* Guarded by a cheap read so the common case costs one indexed lookup and no write: through a
* normal day the nightly run has already brought the row to today and there is nothing to do. The
* refresh only fires when the row is genuinely behind — after the business date moves, or on an
* account whose row the nightly run has not reached yet.
*
* <p>
* A row dated <em>after</em> the business date is refreshed too, not skipped. That is the case
* where the business date has been moved back, and the ledger has to come down to the earlier
* date just as it goes up to a later one.
*
* <p>
* Only savings accounts carry a row here; fixed and recurring deposits accrue through core
* Fineract and are left alone by the refresh itself.
*/
/**
* The dormancy sub-status the elapsed days have earned, whatever the column currently says.
*
* <p>
* The nightly job is what stamps the column, so between an account crossing a threshold and
* that job next running the column understates where the account actually stands — by a day in
* the ordinary case, by longer after an outage or when the business date moves by more than a
* day. Screens that read the column alone then tell the counter an account is fine when the
* transaction is about to be refused.
*
* <p>
* Mirrors the job's own thresholds, which are the product's, and is only ever used to raise the
* reported status, never to lower one the job has already set.
*/
/**
* The dormancy sub-status to report: the one the elapsed days say, in place of the one the
* nightly job last stamped.
*
* <p>
* Both directions, not only upward. Upward closes the gap between an account crossing a
* threshold and the job next running. Downward matters because the stamp can outlive the
* reason for it: a customer transaction resets the clock, and moving the business date back —
* which is how this is tested — leaves a DORMANT stamp on an account only 379 days idle, which
* is inactive. Either way the days are the truth and the stamp is a cache of it.
*
* <p>
* Only the three dormancy states are touched. A blocked account, or one the job has escheated,
* keeps what it has: those are not positions on this ladder, and escheat in particular has
* moved money and closed the account, which no recomputation here can undo.
*/
private static int reportedDormancy(final Integer stamped, final LocalDate lastActive, final LocalDate businessDate,
final Integer daysToInactive, final Integer daysToDormancy) {
final int current = stamped == null ? SavingsAccountSubStatusEnum.NONE.getValue() : stamped;
if (current >= SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
return current;
}
return earnedSubStatus(lastActive, businessDate, daysToInactive, daysToDormancy);
}
private static int earnedSubStatus(final LocalDate lastActive, final LocalDate businessDate, final Integer daysToInactive,
final Integer daysToDormancy) {
if (lastActive == null) {
return SavingsAccountSubStatusEnum.NONE.getValue();
}
final long idle = ChronoUnit.DAYS.between(lastActive, businessDate);
// Escheat is deliberately not anticipated. Unlike the other two it is not a label but a
// movement of money — the job closes the account and transfers the balance to the escheat
// liability — and reporting it before that has happened would show a closed account still
// holding its balance. It stays whatever the job has actually done.
if (daysToDormancy != null && daysToDormancy > 0 && idle >= daysToDormancy) {
return SavingsAccountSubStatusEnum.DORMANT.getValue();
}
if (daysToInactive != null && daysToInactive > 0 && idle >= daysToInactive) {
return SavingsAccountSubStatusEnum.INACTIVE.getValue();
}
return SavingsAccountSubStatusEnum.NONE.getValue();
}
private void refreshInterestLedger(final Long accountId) {
try {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<LocalDate> ledgerDate = this.jdbcTemplate.queryForList(
"select balance_date from m_savings_daily_interest where savings_account_id = ?", LocalDate.class, accountId);
if (!ledgerDate.isEmpty() && DateUtils.isEqual(ledgerDate.get(0), businessDate)) {
return;
}
this.savingsDailyInterestService.refreshAfterBalanceChange(accountId, businessDate);
} catch (final RuntimeException e) {
// Never break the read. The account must still open if its ledger cannot be refreshed;
// the nightly run will pick it up.
log.warn("Could not refresh the interest ledger for savings account {} before reading it", accountId, e);
}
}
@Override @Override
public Collection<SavingsAccountData> retrieveAllForLookup(final Long clientId) { public Collection<SavingsAccountData> retrieveAllForLookup(final Long clientId) {
@@ -269,6 +368,21 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
@Override @Override
public SavingsAccountData retrieveOne(final Long accountId) { public SavingsAccountData retrieveOne(final Long accountId) {
// Bring the interest ledger up to the business date before reading it.
//
// The accrual normally runs once a night, so between the business date moving and the next
// run the ledger is behind and this screen reports interest as of the older date. Moving the
// business date to 30 September and opening the account showed the interest accrued to the
// 9th, and moving it back showed the higher figure — the interest did not follow the date in
// either direction, which is what made it look broken.
//
// refreshAfterBalanceChange is built for exactly this: it rewrites the account's row from the
// day before it rather than from itself, so calling it repeatedly converges instead of
// compounding, it creates the row if the account has none, and it never propagates a failure
// to its caller. That last property is what makes it safe on a read path — a ledger that
// cannot be refreshed must not stop the account from being displayed.
refreshInterestLedger(accountId);
try { try {
final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?"; final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?";
@@ -379,7 +493,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); // Interest earned counts what the daily ledger has accrued but not yet paid.
//
// total_interest_earned_derived only moves when interest is posted, so between two
// posting dates it says the account has earned nothing since the last payout. The screen
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
// (or a stale figure) on accounts the accrual had been building up for weeks — one
// account had accrued 1,024.11 while the screen read 16.29.
//
// m_savings_daily_interest holds one row per account carrying the interest accrued in
// the current cycle, which is exactly the unposted amount. Adding it to what has been
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
// reading their own column exactly as before.
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
// Adding the cumulative figure whole would count that payment twice and report the
// account as having earned 24.90.
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
+ "as totalInterestEarned, ");
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
sqlBuilder.append("sa.account_balance_derived as accountBalance, "); sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
@@ -397,11 +532,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
sqlBuilder.append("tg.id as taxGroupId, "); sqlBuilder.append("tg.id as taxGroupId, ");
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
sqlBuilder.append("from m_savings_account_transaction as sat ");
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
sqlBuilder.append("sp.id as productId, "); sqlBuilder.append("sp.id as productId, ");
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
@@ -521,8 +652,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive");
final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive");
@@ -534,6 +664,20 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
LocalDate currentDate = DateUtils.getBusinessLocalDate(); LocalDate currentDate = DateUtils.getBusinessLocalDate();
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
// Report the sub-status the elapsed days have earned, not only the one the
// nightly job has got round to stamping.
//
// The job stamps DORMANT at midnight, so an account that crossed the
// threshold during the day still reads as active until the next run — and
// that is what the counter screen showed the clerk: "Status: Active", no
// dormancy banner, on an account 23 days past its dormancy date. The clerk
// filled in the whole slip before anything refused it.
//
// The thresholds and the last-active date are already read above for the
// countdown, so this only reuses them. It is a read: the account row is not
// written here, and the job remains what actually stamps the column.
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
numDaysToDormancy);
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate,
lastActiveTransactionDate.plusDays(numDaysToEscheat)); lastActiveTransactionDate.plusDays(numDaysToEscheat));
@@ -547,6 +691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
lastActiveTransactionDate.plusDays(numDaysToInactive)); lastActiveTransactionDate.plusDays(numDaysToInactive));
} }
} }
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate");
final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate");
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
@@ -596,7 +741,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
BigDecimal interestNotPosted = BigDecimal.ZERO; BigDecimal interestNotPosted = BigDecimal.ZERO;
LocalDate lastInterestCalculationDate = null; LocalDate lastInterestCalculationDate = null;
if (totalInterestEarned != null) { if (totalInterestEarned != null) {
// Floored at zero: this is unposted interest, and it cannot be negative.
//
// Earned and posted are two separate columns that a failed or reverted
// posting can leave inconsistent, and the difference going negative used to
// surface on the account screen as "Overdraft Interest Not Posted -25.98" —
// the screen shows this same field under that label whenever it is negative,
// on accounts with no overdraft at all.
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
interestNotPosted = BigDecimal.ZERO;
}
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
} }
@@ -830,7 +985,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); // Interest earned counts what the daily ledger has accrued but not yet paid.
//
// total_interest_earned_derived only moves when interest is posted, so between two
// posting dates it says the account has earned nothing since the last payout. The screen
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
// (or a stale figure) on accounts the accrual had been building up for weeks — one
// account had accrued 1,024.11 while the screen read 16.29.
//
// m_savings_daily_interest holds one row per account carrying the interest accrued in
// the current cycle, which is exactly the unposted amount. Adding it to what has been
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
// reading their own column exactly as before.
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
// Adding the cumulative figure whole would count that payment twice and report the
// account as having earned 24.90.
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
+ "as totalInterestEarned, ");
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
sqlBuilder.append("sa.account_balance_derived as accountBalance, "); sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
@@ -849,11 +1025,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, "); sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, ");
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
sqlBuilder.append("from m_savings_account_transaction as sat ");
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, ");
@@ -948,8 +1120,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
final String reasonForBlock = rs.getString("reasonForBlock"); final String reasonForBlock = rs.getString("reasonForBlock");
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
@@ -963,6 +1134,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
LocalDate currentDate = DateUtils.getBusinessLocalDate(); LocalDate currentDate = DateUtils.getBusinessLocalDate();
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
// The elapsed days decide, not only the column the nightly job stamps — see
// earnedSubStatus. This is the mapper behind the single-account read, which is what
// the counter screen loads when a clerk searches an account, so without this it
// showed "Active" on an account already past its dormancy date and the clerk filled
// in the whole slip before anything refused it.
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
numDaysToDormancy);
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat));
} }
@@ -973,6 +1151,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive)); daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive));
} }
} }
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
final String submittedByUsername = rs.getString("submittedByUsername"); final String submittedByUsername = rs.getString("submittedByUsername");
@@ -1092,7 +1271,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
BigDecimal interestNotPosted = BigDecimal.ZERO; BigDecimal interestNotPosted = BigDecimal.ZERO;
LocalDate lastInterestCalculationDate = null; LocalDate lastInterestCalculationDate = null;
if (totalInterestEarned != null) { if (totalInterestEarned != null) {
// Floored at zero, for the same reason as the summary mapper above: unposted
// interest cannot be negative, and a negative value reaches the account screen
// labelled "Overdraft Interest Not Posted" even on accounts with no overdraft.
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
interestNotPosted = BigDecimal.ZERO;
}
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
} }
@@ -1484,10 +1669,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) ");
sql.append(" where sa.status_enum = 300 "); sql.append(" where sa.status_enum = 300 ");
sql.append(" and sa.sub_status_enum = 0 "); sql.append(" and sa.sub_status_enum = 0 ");
String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) " sql.append(" and ").append(sqlGenerator.dateDiff("?", DormancyClock.LAST_LIVE_ON)).append(" >= sp.days_to_inactive ");
+ "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false"
+ " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)";
sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive ");
try { try {
ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class);
@@ -1509,7 +1691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sql.append(" where sa.status_enum = 300 "); sql.append(" where sa.status_enum = 300 ");
sql.append(" and sa.sub_status_enum = 100 "); sql.append(" and sa.sub_status_enum = 100 ");
sql.append(" and " + sqlGenerator.dateDiff("?", sql.append(" and " + sqlGenerator.dateDiff("?",
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") DormancyClock.LAST_LIVE_ON)
+ " "); + " ");
sql.append(" >= sp.days_to_dormancy "); sql.append(" >= sp.days_to_dormancy ");
@@ -1533,7 +1715,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sql.append(" where sa.status_enum = 300 "); sql.append(" where sa.status_enum = 300 ");
sql.append(" and sa.sub_status_enum = 200 "); sql.append(" and sa.sub_status_enum = 200 ");
sql.append(" and " + sqlGenerator.dateDiff("?", sql.append(" and " + sqlGenerator.dateDiff("?",
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") DormancyClock.LAST_LIVE_ON)
+ " "); + " ");
sql.append(" >= sp.days_to_escheat "); sql.append(" >= sp.days_to_escheat ");
@@ -363,9 +363,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.noteRepository.save(note); this.noteRepository.save(note);
} }
// Money arriving at the counter: cash is debited and the member's savings control account // The voucher itself is raised by handleDeposit() above, off the journal entries it posted —
// is credited. // one slip covering every leg, including any charge collected with the deposit. Read it back
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true); // here only to return it to the caller.
final String depositVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(deposit.getId());
if (depositVoucherNo != null) { if (depositVoucherNo != null) {
changes.put("voucherNo", depositVoucherNo); changes.put("voucherNo", depositVoucherNo);
} }
@@ -446,9 +447,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.noteRepository.save(note); this.noteRepository.save(note);
} }
// Money leaving the member over the counter: their savings control account is debited and // As on the deposit path: handleWithdrawal() raised the slip; this only reads it back.
// the till that pays it out is credited. final String withdrawalVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(withdrawal.getId());
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
if (withdrawalVoucherNo != null) { if (withdrawalVoucherNo != null) {
changes.put("voucherNo", withdrawalVoucherNo); changes.put("voucherNo", withdrawalVoucherNo);
} }
@@ -1513,6 +1513,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
// A charge taken from the balance is a journal voucher, not a debit voucher: no cash crosses
// the counter, the member's balance simply moves to an income head. A branch files these in
// the JV book, which is why the series differs from a counter withdrawal that collects a fee.
this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_CHARGE, VoucherService.REF_SAVINGS_TRANSACTION,
chargeTransaction.getId(), this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL);
return chargeTransaction; return chargeTransaction;
} }
@@ -1766,6 +1772,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
account.escheat(appuserRepository.fetchSystemUser()); account.escheat(appuserRepository.fetchSystemUser());
this.savingAccountRepositoryWrapper.saveAndFlush(account); this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
// A journal voucher for the balance moved to the escheat liability. Raised under the system
// user, not a branch user: this runs from the dormant-accounts batch with nobody at a
// counter, and the register has to say so.
final AppUser systemUser = appuserRepository.fetchSystemUser();
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
if (transaction.getId() != null && !existingTransactionIds.contains(transaction.getId())
&& transaction.isAmountOnHoldNotReleased() == false && transaction.isNotReversed()) {
this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_ESCHEAT, REF_SAVINGS_TRANSACTION,
transaction.getId(), systemUser, VoucherService.PREFIX_JOURNAL);
}
}
} }
private AppUser getAppUserIfPresent() { private AppUser getAppUserIfPresent() {
@@ -2080,46 +2098,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
} }
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT, transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount) List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
.withDescription("Interest posted to " + accountNo), .withDescription("Interest posted to " + accountNo),
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount) VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
.withDescription("Interest credited"))); .withDescription("Interest credited")));
// Without this the voucher exists but nothing ties it to the transaction it paid, so
// the register cannot show the two together and the transaction shows no voucher_no.
this.voucherService.linkSavingsTransaction(REF_SAVINGS_TRANSACTION, transaction.getId(), transaction.getId());
} }
} catch (final RuntimeException e) { } catch (final RuntimeException e) {
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e); log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
} }
} }
private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount, }
final boolean isDeposit) {
if (!this.voucherService.shouldRaiseVoucher()) {
return null;
}
try {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
final String accountNo = account.getAccountNumber();
final VoucherLine memberSide = isDeposit
? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount)
: VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount);
final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount)
: VoucherLine.creditGl(null, "Cash in Hand", null, amount);
final List<VoucherLine> lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide);
return this.voucherService.createVoucherWithLines(
isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId,
currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for savings transaction {}", transactionId, e);
return null;
}
}
}
@@ -183,6 +183,7 @@ import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.Scope;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -426,9 +427,12 @@ public class SavingsConfiguration {
@ConditionalOnMissingBean(SavingsAccountReadPlatformService.class) @ConditionalOnMissingBean(SavingsAccountReadPlatformService.class)
public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate, public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate,
SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator,
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator) { SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
// @Lazy because the accrual service depends on beans from this same package: resolving
// both eagerly closes a cycle and the context fails to start.
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator, return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
sqlGenerator, savingsAccountRepositoryWrapper); sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
} }
@Bean @Bean
@@ -33,6 +33,7 @@ import jakarta.persistence.Transient;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -539,9 +540,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) { public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
ShareAccountTransaction returnTrans = null; ShareAccountTransaction returnTrans = null;
for (ShareAccountTransaction tran : this.shareAccountTransactions) { for (ShareAccountTransaction tran : this.shareAccountTransactions) {
// A charge/fee transaction carries no share count or unit price at all (both columns are
// null), so this account's own transaction list is not uniformly non-null the way a
// purchase-only list would be -- comparing those two fields has to tolerate that instead
// of assuming every transaction here is a share purchase.
if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate()) if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate())
&& tran.getTotalShares().equals(transaction.getTotalShares()) && Objects.equals(tran.getTotalShares(), transaction.getTotalShares())
&& tran.getPurchasePrice().compareTo(transaction.getPurchasePrice()) == 0 && nullSafeCompare(tran.getPurchasePrice(), transaction.getPurchasePrice())
&& tran.getTransactionStatus().equals(transaction.getTransactionStatus()) && tran.getTransactionStatus().equals(transaction.getTransactionStatus())
&& tran.getTransactionType().equals(transaction.getTransactionType())) { && tran.getTransactionType().equals(transaction.getTransactionType())) {
returnTrans = tran; returnTrans = tran;
@@ -551,6 +556,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
return returnTrans; return returnTrans;
} }
private boolean nullSafeCompare(final java.math.BigDecimal a, final java.math.BigDecimal b) {
if (a == null || b == null) {
return a == b;
}
return a.compareTo(b) == 0;
}
public LocalDate getSubmittedDate() { public LocalDate getSubmittedDate() {
return this.submittedDate; return this.submittedDate;
} }
@@ -27,6 +27,7 @@ import java.time.ZoneOffset;
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;
@@ -442,6 +443,7 @@ public class AdditionalShareTrancheFundingService {
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(this.jdbcTemplate, glAccountId);
this.jdbcTemplate.update( this.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, "
@@ -22,6 +22,8 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
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.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -39,11 +41,6 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService { public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
/** Retained profit, where a dividend nobody claimed returns to. */
private static final String PROFIT_GL_CODE = "BANKPROFIT01";
// Fineract convention: 1 = CREDIT, 2 = DEBIT. // Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
@@ -53,6 +50,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
private final SavingsAccountAssembler savingsAccountAssembler; private final SavingsAccountAssembler savingsAccountAssembler;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DividendGlAccountResolver dividendGlAccounts;
@Override @Override
@Transactional @Transactional
@@ -130,16 +128,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
* declaration-time entries. * declaration-time entries.
*/ */
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) { private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
final Long payableDividendsAccountId = this.jdbcTemplate final Long payableDividendsAccountId = this.dividendGlAccounts.payableDividendsAccount();
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, final Long bankProfitAccountId = this.dividendGlAccounts.retainedProfitAccount();
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long bankProfitAccountId = this.jdbcTemplate
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
.orElse(null);
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate(); final LocalDate entryDate = DateUtils.getBusinessLocalDate();
@@ -156,6 +146,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
submitted_on_date, transaction_date) submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
"""; """;
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, payableDividendsAccountId);
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, bankProfitAccountId);
// Debit first, then credit, so the entry ids follow the direction the money travelled. // Debit first, then credit, so the entry ids follow the direction the money travelled.
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT, this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.shareaccounts.start; package org.apache.fineract.portfolio.shareaccounts.start;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
@@ -115,9 +116,9 @@ public class ShareAccountsConfiguration {
@ConditionalOnMissingBean(ShareAccountSchedularService.class) @ConditionalOnMissingBean(ShareAccountSchedularService.class)
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository, public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler, SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) { PlatformSecurityContext context, JdbcTemplate jdbcTemplate, DividendGlAccountResolver dividendGlAccounts) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler, return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
context, jdbcTemplate); context, jdbcTemplate, dividendGlAccounts);
} }
@Bean @Bean
@@ -39,6 +39,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.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;
@@ -77,8 +78,11 @@ public class AllProductsDividendApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT"; private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
private static final int DIVIDEND_STATUS_INITIATED = 100; private static final int DIVIDEND_STATUS_INITIATED = 100;
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */ /**
private static final String PROFIT_GL_CODE = "BANKPROFITND01"; * The account a dividend is declared against, once year-end closing has landed the profit in it — configured as
* the Distributable Profit financial activity rather than found by a hardcoded GL code.
*/
private final DividendGlAccountResolver dividendGlAccounts;
/** /**
* A dividend is a rate on each member's own paid-up capital, and nothing else. * A dividend is a rate on each member's own paid-up capital, and nothing else.
@@ -187,7 +191,7 @@ public class AllProductsDividendApiResource {
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL); out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
out.put("ratePercentage", ratePercentage); out.put("ratePercentage", ratePercentage);
out.put("totalWeightedCapital", totalWeightedCapital); out.put("totalWeightedCapital", totalWeightedCapital);
out.put("profitGlCode", PROFIT_GL_CODE); out.put("profitGlCode", distributableProfitGlCode());
// The funding position, not the basis. Carried so a rate the profit account cannot cover is // The funding position, not the basis. Carried so a rate the profit account cannot cover is
// visible on screen before submission — the command layer enforces the same ceiling, and an // visible on screen before submission — the command layer enforces the same ceiling, and an
// officer should not discover it only when the declaration is rejected. // officer should not discover it only when the declaration is rejected.
@@ -428,9 +432,9 @@ public class AllProductsDividendApiResource {
final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" final BigDecimal closedProfit = 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(fyStart),
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); java.sql.Date.valueOf(fyEnd));
return closedProfit == null ? BigDecimal.ZERO : closedProfit; return closedProfit == null ? BigDecimal.ZERO : closedProfit;
} }
} }
@@ -494,6 +498,12 @@ public class AllProductsDividendApiResource {
} }
/** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */ /** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */
/** The configured Distributable Profit account's GL code, for display on the declaration screen. */
private String distributableProfitGlCode() {
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class,
dividendGlAccounts.distributableProfitAccount());
}
private String financialYearOf(final String isoDate) { private String financialYearOf(final String isoDate) {
final LocalDate date = LocalDate.parse(isoDate); final LocalDate date = LocalDate.parse(isoDate);
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
@@ -29,6 +29,8 @@ import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSource;
@@ -88,10 +90,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private final CommandSourceRepository commandSourceRepository; private final CommandSourceRepository commandSourceRepository;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
// accounting module into this constructor just for two int constants.
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
/** /**
* How many financial years of dividend a member may still come and claim. * How many financial years of dividend a member may still come and claim.
* *
@@ -100,19 +98,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
* money is recognised as income rather than sitting as a liability nobody will ever call on. * money is recognised as income rather than sitting as a liability nobody will ever call on.
*/ */
private static final int CLAIMABLE_FINANCIAL_YEARS = 3; private static final int CLAIMABLE_FINANCIAL_YEARS = 3;
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
/** /**
* The year's closed-out profit a dividend is declared out of; kept in step with * The dividend GL accounts, read from the Financial Activity Mappings a society configures.
* IncomeProfitTransferService.PROFIT_GL_CODE. *
* These were hardcoded GL codes (BANKPROFITND01 / BANKPROFIT01) until the whole dividend lifecycle was made
* configurable — see {@link DividendGlAccountResolver} for why the lookup throws on a missing mapping instead of
* posting nothing.
*/ */
private static final String PROFIT_GL_CODE = "BANKPROFITND01"; private final DividendGlAccountResolver dividendGlAccounts;
/**
* Where profit settles once a declaration has been approved — the undistributed remainder and the rounding
* residue. Nothing here ever enters a later dividend calculation.
*/
private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01";
// Fineract convention: 1 = CREDIT, 2 = DEBIT. // Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
@@ -301,11 +295,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
return; return;
} }
final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE); final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount();
final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); final Long settledAccountId = dividendGlAccounts.retainedProfitAccount();
if (distributableAccountId == null || settledAccountId == null) {
return;
}
final String financialYear = financialYearOf(periodEndDate); final String financialYear = financialYearOf(periodEndDate);
// Dated to the day of approval, not the period end. Closing off the remainder is an act of // Dated to the day of approval, not the period end. Closing off the remainder is an act of
@@ -357,14 +348,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue(); final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue();
final BigDecimal residue = declaredAmount.subtract(allocatedAmount); final BigDecimal residue = declaredAmount.subtract(allocatedAmount);
// Swept to the settled account, not back to the pool it came from: the residue belongs to the // Resolved here so a missing mapping is reported while the declaration can still be abandoned, rather than
// year that was just declared, and returning it to the distributable balance would put it // first surfacing at approval. Never back to the distributable pool it came from: the residue belongs to the
// straight back into what the next declaration measures. // year just declared, and returning it there would put it straight into what the next declaration measures.
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); dividendGlAccounts.dividendRoundOffAccount();
final Long payableDividendsAccountId = jdbcTemplate dividendGlAccounts.payableDividendsAccount();
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// A residue of zero still gets its row: "nothing was left over" is itself the answer to the // A residue of zero still gets its row: "nothing was left over" is itself the answer to the
// audit question, and its absence would be indistinguishable from the sweep never running. // audit question, and its absence would be indistinguishable from the sweep never running.
@@ -411,26 +399,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
return; return;
} }
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); // The round-off account when the society has mapped one, retained profit otherwise — see
final Long payableDividendsAccountId = jdbcTemplate // DividendGlAccountResolver#dividendRoundOffAccount for why this one resolves rather than throwing.
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, final Long roundOffAccountId = dividendGlAccounts.dividendRoundOffAccount();
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
.stream().findFirst().orElse(null);
// Left unswept rather than forced through: swept_to_profit_date stays null so the residue is
// still identifiable and can be swept once the GL setup is completed.
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
return;
}
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate(); final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
final String transactionId = "DIVROUND" + dividendPayOutId; final String transactionId = "DIVROUND" + dividendPayOutId;
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice // Approval credited the whole declared amount to Dividend Payable; this takes back the slice
// that will never be paid to a member and returns it to retained profit. // that will never be paid to a member, because no member's share divided into it evenly.
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId, postJournalEntryPair(payableDividendsAccountId, roundOffAccountId, residue, (String) row.get("currency_code"), transactionId,
sweptDate, sweptDate,
"FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId); "FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept — declaration #" + dividendPayOutId);
jdbcTemplate.update(""" jdbcTemplate.update("""
UPDATE m_share_dividend_rounding_residue UPDATE m_share_dividend_rounding_residue
@@ -511,9 +493,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" final BigDecimal closedProfit = 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(fyStart),
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); java.sql.Date.valueOf(fyEnd));
return closedProfit == null ? BigDecimal.ZERO : closedProfit; return closedProfit == null ? BigDecimal.ZERO : closedProfit;
} }
} }
@@ -543,11 +525,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
} }
private Long glAccountIdByCode(final String glCode) {
return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst()
.orElse(null);
}
/** /**
* Declaring a dividend charges it against the society's retained profit and creates the obligation to members. * Declaring a dividend charges it against the society's retained profit and creates the obligation to members.
* *
@@ -564,24 +541,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
* year-end closing, so the round trip reconciles. * year-end closing, so the round trip reconciles.
* *
* Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which * Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which
* expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting * expects a loan/savings/share *transaction* rather than a standalone declaration event.
* Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by *
* incomplete GL setup. * Both accounts must be mapped; there is no longer a degraded path that posts straight to Dividend Payable when
* Reserve is absent. Skipping the appropriation would put a liability to members on the books at declaration, which
* is exactly what routing through Reserve exists to prevent — so an unmapped Reserve is a setup error to be
* reported, not a variation to be absorbed.
*/ */
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount, private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount,
final String financialYear) { final String financialYear) {
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); final Long bankProfitAccountId = dividendGlAccounts.distributableProfitAccount();
final Long payableDividendsAccountId = jdbcTemplate final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount();
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long reserveAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
.stream().findFirst().orElse(null);
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
@@ -591,12 +561,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
// across years, and without the year on the line the balances only ever give a running total. // across years, and without the year on the line the balances only ever give a running total.
final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId; final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId;
if (reserveAccountId == null) {
postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate,
description);
return;
}
// Only the appropriation posts here. The release to members is a separate act that waits for // Only the appropriation posts here. The release to members is a separate act that waits for
// approval — see releaseReserveToDividendPayable. Posting both at declaration would put a // approval — see releaseReserveToDividendPayable. Posting both at declaration would put a
// liability to members on the books before anyone had approved paying them. // liability to members on the books before anyone had approved paying them.
@@ -619,20 +583,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
* Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside. * Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside.
*/ */
private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) { private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) {
final Long reserveAccountId = jdbcTemplate final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount();
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
.stream().findFirst().orElse(null);
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend
// Payable, so the liability is on the books and there is nothing left to release.
if (reserveAccountId == null || payableDividendsAccountId == null) {
return;
}
// Read back from the row rather than the entity: the amount and product are not exposed as // Read back from the row rather than the entity: the amount and product are not exposed as
// getters, and this matches how closeOffUndistributedProfit reads the same declaration. // getters, and this matches how closeOffUndistributedProfit reads the same declaration.
@@ -660,6 +612,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) { final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
@@ -745,11 +699,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
return; return;
} }
final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS); final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); final Long bankProfitAccountId = dividendGlAccounts.retainedProfitAccount();
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
return;
}
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
for (final Map<String, Object> lock : expired) { for (final Map<String, Object> lock : expired) {
@@ -769,14 +720,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
} }
} }
/** A GL account mapped to a financial activity, or null when the society has not configured it. */
private Long glAccountIdByFinancialActivity(final int financialActivityType) {
return jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
financialActivityType)
.stream().findFirst().orElse(null);
}
private void makeDividendsClaimable(final Long payOutDetailId) { private void makeDividendsClaimable(final Long payOutDetailId) {
final LocalDate today = DateUtils.getBusinessLocalDate(); final LocalDate today = DateUtils.getBusinessLocalDate();
final java.sql.Date lockedDate = java.sql.Date.valueOf(today); final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.shareproducts.starter; package org.apache.fineract.portfolio.shareproducts.starter;
import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService; import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
@@ -117,11 +118,13 @@ public class ShareProductsConfiguration {
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository, ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
ProductPendingEditChargeRepository pendingEditChargeRepository, ProductPendingEditChargeRepository pendingEditChargeRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) { CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate,
DividendGlAccountResolver dividendGlAccounts) {
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper, return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService, shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository, businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate); pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate,
dividendGlAccounts);
} }
} }
@@ -52,11 +52,13 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDRevokeRequestWritePlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -72,6 +74,7 @@ public class SponsorDDApiResource {
private final SponsorDDReconciliationService reconciliationService; private final SponsorDDReconciliationService reconciliationService;
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService; private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@@ -426,6 +429,49 @@ public class SponsorDDApiResource {
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason())); return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
} }
// ── Revoking a leaflet spoiled at the printer ──────────────────────────
@POST
@Path("leaflets/{leafletId}/revoke-request")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Request to revoke a spoiled DD and replace it",
description = "Clerk (maker) step: the leaflet was ruined at the printer, so its draft must move onto the next available blank. Distinct from a reprint, which re-prints the same leaflet.")
public CommandProcessingResult requestRevoke(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) {
return safely(() -> this.revokeRequestWritePlatformService.requestRevoke(leafletId, request.getReason()));
}
@GET
@Path("revoke-requests")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DD revoke requests",
description = "Returns all DD revoke requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.")
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(@QueryParam("status") final String status) {
return this.readPlatformService.retrieveAllRevokeRequests(status);
}
@POST
@Path("revoke-requests/{revokeRequestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a DD revoke request",
description = "Manager/head-cashier (checker) step: marks the spoiled leaflet REVOKED and moves its draft onto the next available blank. No money moves - the customer was already debited and is still owed the same draft.")
public CommandProcessingResult approveRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId) {
return safely(() -> this.revokeRequestWritePlatformService.approveRevokeRequest(revokeRequestId));
}
@POST
@Path("revoke-requests/{revokeRequestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a DD revoke request",
description = "Manager/head-cashier (checker) step: rejects a pending DD revoke request. The leaflet stays issued.")
public CommandProcessingResult rejectRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId,
final RejectIssueRequestRequest request) {
return safely(() -> this.revokeRequestWritePlatformService.rejectRevokeRequest(revokeRequestId, request.getRejectionReason()));
}
// ── Amending an issued DD (beneficiary, payable-at branch and city) ──── // ── Amending an issued DD (beneficiary, payable-at branch and city) ────
@POST @POST
@@ -59,4 +59,20 @@ public class SponsorDDLeafletData implements Serializable {
private String approverUsername; private String approverUsername;
/** Set while a correction to this draft is awaiting a manager. */ /** Set while a correction to this draft is awaiting a manager. */
private Long pendingAmendmentRequestId; private Long pendingAmendmentRequestId;
/** Set while a revoke is awaiting a checker, so the register offers it only once. */
private Long pendingRevokeRequestId;
/**
* Where a spoiled leaflet's draft went, and which spoiled leaflet a replacement stands in for.
*
* <p>
* Both directions are shown in the register: a revoked leaflet reads as consumed stock with a
* pointer to the draft that survived it, and the replacement says what it was issued against.
* Without the pair the pad simply comes up short against the sponsor bank's range.
*/
private Long revokedReplacedByLeafletId;
private String revokedReplacedByDdNumber;
private Long revokedReplacementOfLeafletId;
private String revokedReplacementOfDdNumber;
} }
@@ -0,0 +1,66 @@
/**
* 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.sponsordd.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* A request to replace a leaflet spoiled at the printer.
*
* <p>
* Carries the draft's own details as well as the request's, because a checker approving this is
* deciding to consume a blank from limited stock - they need to see whose draft it is and for how
* much without opening the register in another tab.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SponsorDDRevokeRequestData implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long leafletId;
private String ddNumber;
private String reason;
private String status;
/** Null until approved: no blank is consumed while the request is still pending. */
private Long replacementLeafletId;
private String replacementDdNumber;
/** The draft being moved, so the approval queue can be read on its own. */
private String beneficiaryName;
private String clientName;
private BigDecimal faceValue;
private String sponsorBankName;
private Long makerId;
private String makerUsername;
private LocalDateTime createdDate;
private Long checkerId;
private String checkerUsername;
private LocalDateTime checkerDate;
private String rejectionReason;
}
@@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
public interface SponsorDDReadPlatformService { public interface SponsorDDReadPlatformService {
@@ -59,6 +60,9 @@ public interface SponsorDDReadPlatformService {
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status); Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
/** Requests to replace a leaflet spoiled at the printer, for the manager approval queue. */
Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(String status);
/** DD amendment requests for the approval queue, optionally narrowed to one status. */ /** DD amendment requests for the approval queue, optionally narrowed to one status. */
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status); Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -75,6 +76,18 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
} }
} }
/**
* Series order for a book of drafts.
*
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 — which handed the
* clerk draft 10 immediately after draft 1 and showed the register out of order. Pre-printed
* stationery has to be consumed in series, so a wholly numeric series is cast and ordered
* numerically; a series carrying a prefix keeps text order, which is already its own sequence.
*/
private static final String LEAFLET_SERIES_ORDER =
"order by case when leaf.dd_number ~ '^[0-9]+$' then cast(leaf.dd_number as bigint) end asc nulls last, "
+ "leaf.dd_number asc";
private static final String LEAFLET_SELECT = private static final String LEAFLET_SELECT =
"select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, " "select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, "
+ "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, " + "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, "
@@ -83,7 +96,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, " + "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, " + "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
+ "apr.checker_id as approver_user_id, approver.username as approver_username, " + "apr.checker_id as approver_user_id, approver.username as approver_username, "
+ "amr.id as pending_amendment_request_id " + "amr.id as pending_amendment_request_id, rvr.id as pending_revoke_request_id, "
+ "leaf.revoked_replaced_by_leaflet_id, rby.dd_number as revoked_replaced_by_dd_number, "
+ "leaf.revoked_replacement_of_leaflet_id, rof.dd_number as revoked_replacement_of_dd_number "
+ "from m_sponsor_dd_leaflet leaf " + "from m_sponsor_dd_leaflet leaf "
+ "left join m_client c on c.id = leaf.issued_to_client_id " + "left join m_client c on c.id = leaf.issued_to_client_id "
+ "left join m_office o on o.id = leaf.issuing_branch_id " + "left join m_office o on o.id = leaf.issuing_branch_id "
@@ -94,11 +109,20 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
+ "left join m_appuser approver on approver.id = apr.checker_id " + "left join m_appuser approver on approver.id = apr.checker_id "
// Lets the registry show that a correction is already awaiting a manager, instead of // Lets the registry show that a correction is already awaiting a manager, instead of
// offering Edit again and letting a second request be raised over the same draft. // offering Edit again and letting a second request be raised over the same draft.
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "; + "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "
// Same reason as the amendment join: the register must not offer Revoke twice over one
// spoiled leaflet while a checker still has the first request.
+ "left join m_sponsor_dd_revoke_request rvr on rvr.leaflet_id = leaf.id and rvr.status = 'PENDING' "
// Both ends of a replacement, so the register can say where a revoked draft went and
// what a replacement was issued against.
+ "left join m_sponsor_dd_leaflet rby on rby.id = leaf.revoked_replaced_by_leaflet_id "
+ "left join m_sponsor_dd_leaflet rof on rof.id = leaf.revoked_replacement_of_leaflet_id ";
@Override @Override
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) { public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc"; // Numeric-aware, for the same reason as retrieveNextAvailableLeaflet: a text sort shows the
// register as 1, 10, 2, 3 and the book reads as though it were issued out of order.
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? " + LEAFLET_SERIES_ORDER;
return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId); return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId);
} }
@@ -170,11 +194,22 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
final String approverUsername = rs.getString("approver_username"); final String approverUsername = rs.getString("approver_username");
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
? rs.getLong("pending_amendment_request_id") : null; ? rs.getLong("pending_amendment_request_id") : null;
final Long pendingRevokeRequestId = rs.getObject("pending_revoke_request_id") != null
? rs.getLong("pending_revoke_request_id")
: null;
final Long revokedReplacedByLeafletId = rs.getObject("revoked_replaced_by_leaflet_id") != null
? rs.getLong("revoked_replaced_by_leaflet_id")
: null;
final Long revokedReplacementOfLeafletId = rs.getObject("revoked_replacement_of_leaflet_id") != null
? rs.getLong("revoked_replacement_of_leaflet_id")
: null;
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName, return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate, payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId, pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
approverUserId, approverUsername, pendingAmendmentRequestId); approverUserId, approverUsername, pendingAmendmentRequestId, pendingRevokeRequestId,
revokedReplacedByLeafletId, rs.getString("revoked_replaced_by_dd_number"), revokedReplacementOfLeafletId,
rs.getString("revoked_replacement_of_dd_number"));
} }
} }
@@ -414,6 +449,54 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
return this.jdbcTemplate.query(sql, new ReprintRequestMapper()); return this.jdbcTemplate.query(sql, new ReprintRequestMapper());
} }
/**
* The request, the draft it is about, and the blank that replaced it.
*
* <p>
* Joined out to the leaflet and the client because approving this consumes a blank from limited
* stock: the checker is deciding about a specific customer's draft, not an abstract request id.
*/
private static final String REVOKE_REQUEST_SELECT =
"select rv.id, rv.leaflet_id, leaf.dd_number, rv.reason, rv.status, rv.replacement_leaflet_id, "
+ "repl.dd_number as replacement_dd_number, leaf.beneficiary_name, "
+ "coalesce(c.display_name, leaf.walkin_purchaser_name) as client_name, leaf.face_value, "
+ "sb.name as sponsor_bank_name, rv.maker_id, maker.username as maker_username, rv.created_date, "
+ "rv.checker_id, checker.username as checker_username, rv.checker_date, rv.rejection_reason "
+ "from m_sponsor_dd_revoke_request rv "
+ "join m_sponsor_dd_leaflet leaf on leaf.id = rv.leaflet_id "
+ "left join m_sponsor_dd_leaflet repl on repl.id = rv.replacement_leaflet_id "
+ "left join m_client c on c.id = leaf.issued_to_client_id "
+ "left join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id "
+ "left join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id "
+ "join m_appuser maker on maker.id = rv.maker_id "
+ "left join m_appuser checker on checker.id = rv.checker_id ";
@Override
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(final String status) {
if (status != null && !status.isBlank()) {
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "where rv.status = ? order by rv.id desc",
new RevokeRequestMapper(), status);
}
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "order by rv.id desc", new RevokeRequestMapper());
}
private static final class RevokeRequestMapper implements RowMapper<SponsorDDRevokeRequestData> {
@Override
public SponsorDDRevokeRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return new SponsorDDRevokeRequestData(rs.getLong("id"), rs.getLong("leaflet_id"), rs.getString("dd_number"),
rs.getString("reason"), rs.getString("status"),
rs.getObject("replacement_leaflet_id") != null ? rs.getLong("replacement_leaflet_id") : null,
rs.getString("replacement_dd_number"), rs.getString("beneficiary_name"), rs.getString("client_name"),
rs.getBigDecimal("face_value"), rs.getString("sponsor_bank_name"), rs.getLong("maker_id"),
rs.getString("maker_username"),
rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null,
rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null, rs.getString("checker_username"),
rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null,
rs.getString("rejection_reason"));
}
}
private static final class ReprintRequestMapper implements RowMapper<SponsorDDReprintRequestData> { private static final class ReprintRequestMapper implements RowMapper<SponsorDDReprintRequestData> {
@Override @Override
public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
@@ -502,7 +585,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
sql += " AND leaf.advice_status = ? "; sql += " AND leaf.advice_status = ? ";
params.add(adviceStatus); params.add(adviceStatus);
} }
sql += " ORDER BY leaf.dd_number ASC"; sql += " " + LEAFLET_SERIES_ORDER;
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray()); return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
} }
@@ -526,7 +609,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
sql.append("and inv.sponsor_bank_id = ? "); sql.append("and inv.sponsor_bank_id = ? ");
params.add(sponsorBankId); params.add(sponsorBankId);
} }
sql.append("order by inv.id asc, leaf.dd_number asc limit 1"); // Oldest book first, then strictly up its own series — see LEAFLET_SERIES_ORDER.
sql.append("order by inv.id asc, ");
sql.append(LEAFLET_SERIES_ORDER.substring("order by ".length()));
sql.append(" limit 1");
try { try {
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray()); return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
@@ -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.sponsordd.service;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/**
* Replacing a draft whose leaflet was spoiled at the printer.
*
* <p>
* Distinct from a reprint, which puts the same draft through the printer again on the same leaflet.
* Here the paper itself is ruined: its printed number can never be handed to anyone, so the draft
* has to move onto the next blank in the pad. Both leaflets are then accounted for - one spoiled,
* one carrying the draft - which is what lets the branch's stock reconcile against the pad the
* sponsor bank sent.
*
* <p>
* No money moves. The customer was debited when the draft was issued and is still owed exactly one
* draft for exactly that amount; only the instrument changes.
*/
public interface SponsorDDRevokeRequestWritePlatformService {
/** Raised by the branch that spoiled the leaflet. Nothing moves until a checker approves. */
CommandProcessingResult requestRevoke(Long leafletId, String reason);
/**
* Marks the spoiled leaflet REVOKED and moves its draft onto the next available blank.
*
* @return the request id, with the replacement leaflet as the sub-entity
*/
CommandProcessingResult approveRevokeRequest(Long revokeRequestId);
CommandProcessingResult rejectRevokeRequest(Long revokeRequestId, String rejectionReason);
}
@@ -0,0 +1,288 @@
/**
* 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.sponsordd.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SponsorDDRevokeRequestWritePlatformServiceImpl implements SponsorDDRevokeRequestWritePlatformService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_APPROVED = "APPROVED";
private static final String STATUS_REJECTED = "REJECTED";
private static final String LEAFLET_AVAILABLE = "AVAILABLE";
private static final String LEAFLET_ISSUED = "ISSUED";
private static final String LEAFLET_REVOKED = "REVOKED";
/**
* Series order for pre-printed stationery.
*
* <p>
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 - which handed out
* draft 10 as the replacement for draft 1 while drafts 2 to 9 sat unused, and left the pad
* consumed out of sequence with no way to reconcile it against the sponsor bank's range. A
* wholly numeric series is cast and ordered numerically; a series carrying a prefix keeps text
* order, which is already its own sequence.
*/
private static final String SERIES_ORDER =
"case when l.dd_number ~ '^[0-9]+$' then cast(l.dd_number as bigint) end asc nulls last, l.dd_number asc";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Override
@Transactional
public CommandProcessingResult requestRevoke(final Long leafletId, final String reason) {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
if (!LEAFLET_ISSUED.equals(leaflet.get("status"))) {
throw new IllegalStateException(
"Only an issued Demand Draft can be revoked. Current status: " + leaflet.get("status"));
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("A reason is required to revoke a spoiled Demand Draft.");
}
if (!this.jdbcTemplate
.queryForList("SELECT id FROM m_sponsor_dd_revoke_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId)
.isEmpty()) {
throw new IllegalStateException(
"A revoke request is already pending for this Demand Draft. Wait for it to be approved or rejected.");
}
// Checked at request time as well as at approval: a clerk who has run out of blank drafts
// needs to know now, while the customer is still at the counter, rather than after a
// checker has been asked to approve something that cannot be carried out.
if (findReplacementLeaflet(leafletId) == null) {
throw new IllegalStateException("No blank Demand Draft is available to replace this one - "
+ "not in this series, elsewhere in this sponsor bank's stock, or with any other sponsor bank. "
+ "Add fresh DD inventory before revoking.");
}
final KeyHolder kh = new GeneratedKeyHolder();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(con -> {
final java.sql.PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_sponsor_dd_revoke_request (leaflet_id, reason, status, maker_id, created_date) "
+ "VALUES (?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, leafletId);
ps.setString(2, reason);
ps.setString(3, STATUS_PENDING);
ps.setLong(4, currentUser.getId());
ps.setObject(5, now);
return ps;
}, kh);
return new CommandProcessingResultBuilder().withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
}
@Override
@Transactional
public CommandProcessingResult approveRevokeRequest(final Long revokeRequestId) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot approve your own revoke request.");
}
final Long spoiledId = ((Number) request.get("leaflet_id")).longValue();
final Map<String, Object> spoiled = findLeafletOrThrow(spoiledId);
if (!LEAFLET_ISSUED.equals(spoiled.get("status"))) {
throw new IllegalStateException(
"This Demand Draft is no longer issued. Current status: " + spoiled.get("status"));
}
// Re-checked here rather than trusted from request time: stock moves, and the blank that was
// free when the clerk asked may have been issued to someone else in the meantime.
final Map<String, Object> replacement = findReplacementLeaflet(spoiledId);
if (replacement == null) {
throw new IllegalStateException("No blank Demand Draft is available to replace this one. "
+ "Add fresh DD inventory, then approve this request.");
}
final Long replacementId = ((Number) replacement.get("id")).longValue();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
// The draft moves wholesale onto the new leaflet - same customer, same beneficiary, same
// amount. Nothing is recalculated: the customer was debited once, for this draft, and is
// owed exactly this draft on whatever paper the branch can actually print.
this.jdbcTemplate.update("UPDATE m_sponsor_dd_leaflet SET status = ?, face_value = ?, issued_to_client_id = ?, "
+ "beneficiary_name = ?, issuing_branch_id = ?, issued_date = ?, walkin_purchaser_name = ?, "
+ "walkin_purchaser_address = ?, walkin_purchaser_aadhaar_masked = ?, "
+ "revoked_replacement_of_leaflet_id = ?, lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
LEAFLET_ISSUED, spoiled.get("face_value"), spoiled.get("issued_to_client_id"),
spoiled.get("beneficiary_name"), spoiled.get("issuing_branch_id"), spoiled.get("issued_date"),
spoiled.get("walkin_purchaser_name"), spoiled.get("walkin_purchaser_address"),
spoiled.get("walkin_purchaser_aadhaar_masked"), spoiledId, currentUser.getId(), now, replacementId);
// The spoiled leaflet keeps its beneficiary and amount. It is a record of a draft that was
// printed and destroyed, not an empty slot, and blanking it would leave the pad short by one
// with nothing to say where it went.
this.jdbcTemplate.update(
"UPDATE m_sponsor_dd_leaflet SET status = ?, revoked_replaced_by_leaflet_id = ?, "
+ "lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
LEAFLET_REVOKED, replacementId, currentUser.getId(), now, spoiledId);
this.jdbcTemplate.update(
"UPDATE m_sponsor_dd_revoke_request SET status = ?, replacement_leaflet_id = ?, checker_id = ?, "
+ "checker_date = ? WHERE id = ?",
STATUS_APPROVED, replacementId, currentUser.getId(), now, revokeRequestId);
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).withSubEntityId(replacementId).build();
}
@Override
@Transactional
public CommandProcessingResult rejectRevokeRequest(final Long revokeRequestId, final String rejectionReason) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot reject your own revoke request.");
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(
"UPDATE m_sponsor_dd_revoke_request SET status = ?, checker_id = ?, checker_date = ?, "
+ "rejection_reason = ? WHERE id = ?",
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, revokeRequestId);
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).build();
}
/**
* The blank that should carry the draft instead, searched in the order a branch would reach for
* one.
*
* <p>
* Nearest first: the next number in the same pad, so the series stays unbroken and the sponsor
* bank's stock reconciles against one continuous range. Then anywhere else in the same sponsor
* bank's stock - a second pad from the same bank is still that bank's paper and its drafts are
* honoured the same way. Only then another sponsor bank, which is a real fallback rather than a
* preference: the draft is drawn on a different bank, so the branch would rather not, but a
* customer standing at the counter needs a draft more than the branch needs a tidy series.
*
* <p>
* Null when the branch has no blank draft anywhere, which is a stock problem and is reported as
* one rather than silently issuing nothing.
*/
private Map<String, Object> findReplacementLeaflet(final Long spoiledLeafletId) {
// The next number after the spoiled one, so the pad is consumed in the order it was
// printed. Falls back to the lowest free number in the pad when the spoiled draft was the
// last one, rather than skipping the series entirely.
final List<Map<String, Object>> afterSpoiled = this.jdbcTemplate.queryForList(
"SELECT l.id FROM m_sponsor_dd_leaflet l "
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
+ "AND l.status = ? AND l.id <> ? "
+ "AND l.dd_number ~ '^[0-9]+$' "
+ "AND cast(l.dd_number as bigint) > "
+ " (SELECT case when s.dd_number ~ '^[0-9]+$' then cast(s.dd_number as bigint) else -1 end "
+ " FROM m_sponsor_dd_leaflet s WHERE s.id = ?) "
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId, spoiledLeafletId);
if (!afterSpoiled.isEmpty()) {
return afterSpoiled.get(0);
}
final List<Map<String, Object>> sameInventory = this.jdbcTemplate.queryForList(
"SELECT l.id FROM m_sponsor_dd_leaflet l "
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
+ "AND l.status = ? AND l.id <> ? "
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
if (!sameInventory.isEmpty()) {
return sameInventory.get(0);
}
final List<Map<String, Object>> sameBank = this.jdbcTemplate.queryForList(
"SELECT l.id FROM m_sponsor_dd_leaflet l "
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
+ "WHERE i.sponsor_bank_id = (SELECT i2.sponsor_bank_id FROM m_sponsor_dd_inventory i2 "
+ " JOIN m_sponsor_dd_leaflet l2 ON l2.inventory_id = i2.id WHERE l2.id = ?) "
+ "AND l.status = ? AND l.id <> ? "
+ "ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
if (!sameBank.isEmpty()) {
return sameBank.get(0);
}
final List<Map<String, Object>> anyBank = this.jdbcTemplate.queryForList(
"SELECT l.id FROM m_sponsor_dd_leaflet l "
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
+ "WHERE l.status = ? AND l.id <> ? ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
LEAFLET_AVAILABLE, spoiledLeafletId);
return anyBank.isEmpty() ? null : anyBank.get(0);
}
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Demand Draft leaflet not found.");
}
return rows.get(0);
}
private Map<String, Object> findRevokeRequestOrThrow(final Long revokeRequestId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_sponsor_dd_revoke_request WHERE id = ?", revokeRequestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Sponsor DD revoke request not found.");
}
return rows.get(0);
}
private boolean isCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
}
private void validateCheckerRole(final AppUser user) {
if (!isCheckerRole(user)) {
throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject revoke requests.");
}
}
}
@@ -0,0 +1,70 @@
/**
* 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.tds.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.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
import org.apache.fineract.portfolio.tds.service.TdsInterestReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/tds/deposit-interest")
@Component
@Tag(name = "TDS on deposit interest",
description = "Interest credited on deposits in a financial year, per customer, for the section 194A deduction.")
@RequiredArgsConstructor
public class TdsInterestApiResource {
private final PlatformSecurityContext context;
private final TdsInterestReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<TdsDeducteeData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Deposit interest per customer for a financial year",
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
this.context.authenticatedUser();
final List<TdsDeducteeData> deductees = this.readPlatformService
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
return this.toApiJsonSerializer.serialize(deductees);
}
/**
* The financial year the books are currently in, off the business date rather than the wall
* clock, so a back-dated working day reports the year that day belongs to.
*/
private int currentFinancialYear() {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
return businessDate.getMonthValue() >= 4 ? businessDate.getYear() : businessDate.getYear() - 1;
}
}
@@ -0,0 +1,54 @@
/**
* 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.tds.data;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.List;
/**
* A customer whose deposit interest the branch must consider for deduction under section 194A,
* together with the accounts and the interest actually credited to them.
*
* <p>
* The section bites on time deposits, so savings accounts are carried here as well but marked by
* their own {@code depositTypeCode} - the caller keeps them out of the aggregate and shows them
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
* customer holds a savings balance at all, which is the first thing an officer checks when a
* customer disputes a deduction.
*/
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
/**
* One deposit account and the interest posted to it.
*
* @param depositTypeCode
* Fineract's own code - {@code depositAccountType.fixedDeposit},
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
* the charge applies at all, so it is sent as the platform states it rather than as
* a flag this report worked out for itself.
*/
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
/** One interest posting: the date it was credited and the amount. */
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
implements Serializable {}
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.tds.service;
import java.util.List;
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
public interface TdsInterestReadPlatformService {
/**
* Deposit interest credited in a financial year, per customer.
*
* @param financialYear
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
* 31 Mar 2027
*/
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
}
@@ -0,0 +1,206 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.tds.service;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
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.portfolio.tds.data.TdsDeducteeData;
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
* rather than from a balance and a rate.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
/**
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
*
* <p>
* This single condition is what makes the figure an interest figure. A deposit is the
* customer's own money arriving and is not income; a withdrawal and a fee are not income
* either. Only what the bank itself credited as interest can be deducted against, so the
* transaction type is filtered here rather than being inferred later from an amount.
*
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
*/
private static final int INTEREST_POSTING = 3;
/**
* Every deposit the customer holds, with the interest credited to each inside the year.
*
* <p>
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
* keeps their interest out of the aggregate - but the branch still has to see that the customer
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
*
* <p>
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
* deduct tax on interest the customer never received.
*/
private static final String SQL = """
SELECT c.id AS client_id,
c.account_no AS customer_id,
c.display_name AS name,
c.date_of_birth AS date_of_birth,
k.pan_number AS pan,
k.form15_type AS form15_type,
o.name AS branch_name,
sa.id AS account_id,
sa.account_no AS account_no,
sa.activatedon_date AS opened_on,
CASE sa.deposit_type_enum
WHEN 100 THEN 'depositAccountType.savingsDeposit'
WHEN 200 THEN 'depositAccountType.fixedDeposit'
WHEN 300 THEN 'depositAccountType.recurringDeposit'
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
t.transaction_date AS credit_date,
t.amount AS credit_amount
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id
LEFT JOIN m_savings_account_transaction t
ON t.savings_account_id = sa.id
AND t.transaction_type_enum = ?
AND t.is_reversed = false
AND t.transaction_date >= ?
AND t.transaction_date <= ?
WHERE sa.client_id IS NOT NULL
ORDER BY c.display_name, sa.account_no, t.transaction_date
""";
private final JdbcTemplate jdbcTemplate;
@Override
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
// The Indian financial year, 1 April to 31 March.
final LocalDate from = LocalDate.of(financialYear, 4, 1);
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
jdbcTemplate.query(SQL, rs -> {
final long clientId = rs.getLong("client_id");
final Deductee deductee = byClient.computeIfAbsent(clientId,
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
readString(rs, "form15_type")));
final long accountId = rs.getLong("account_id");
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
// Null where the outer join found no posting inside the year: the account is still
// listed, it simply earned nothing in the window.
final java.sql.Date creditDate = rs.getDate("credit_date");
if (creditDate != null) {
final BigDecimal amount = rs.getBigDecimal("credit_amount");
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
}
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
final List<TdsDeducteeData> result = new ArrayList<>();
for (final Deductee d : byClient.values()) {
final List<TdsAccountData> accounts = new ArrayList<>();
for (final Account a : d.accounts.values()) {
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
// to decide whose PAN carries the deduction. Fineract does not model it on the
// savings account, so it is left null and the caller falls back to the account
// holder rather than inventing a joint holder who may not exist.
accounts.add(
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
}
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
d.form15Type, accounts));
}
return result;
}
private static String readString(final ResultSet rs, final String column) {
try {
return rs.getString(column);
} catch (final SQLException e) {
throw new IllegalStateException("Could not read column " + column, e);
}
}
private static LocalDate readDate(final ResultSet rs, final String column) {
try {
final java.sql.Date value = rs.getDate(column);
return value == null ? null : value.toLocalDate();
} catch (final SQLException e) {
throw new IllegalStateException("Could not read column " + column, e);
}
}
/** Mutable accumulators, private to the grouping above. */
private static final class Deductee {
private final Long clientId;
private final String customerId;
private final String name;
private final String pan;
private final LocalDate dateOfBirth;
private final String branchName;
private final String form15Type;
private final Map<Long, Account> accounts = new LinkedHashMap<>();
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
String branchName, String form15Type) {
this.clientId = clientId;
this.customerId = customerId;
this.name = name;
this.pan = pan;
this.dateOfBirth = dateOfBirth;
this.branchName = branchName;
this.form15Type = form15Type;
}
}
private static final class Account {
private final Long id;
private final String accountNo;
private final String depositTypeCode;
private final LocalDate openedOn;
private final List<TdsInterestCreditData> credits = new ArrayList<>();
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
this.id = id;
this.accountNo = accountNo;
this.depositTypeCode = depositTypeCode;
this.openedOn = openedOn;
}
}
}
@@ -435,7 +435,14 @@ public class VoucherVerificationApiResource {
@Path("report") @Path("report")
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to, public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType, @QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType,
@QueryParam("cashOnly") final Boolean cashOnly) { @QueryParam("cashOnly") final Boolean cashOnly,
@QueryParam("cashiersOnly") final Boolean cashiersOnlyParam,
@QueryParam("systemOnly") final Boolean systemOnlyParam) {
// Defaults to false so an existing caller that does not send the flag keeps seeing the whole
// register; the two counter screens opt in.
final boolean cashiersOnly = Boolean.TRUE.equals(cashiersOnlyParam);
final boolean systemOnly = Boolean.TRUE.equals(systemOnlyParam);
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
final boolean seesEveryone = isCheckerRole(currentUser); final boolean seesEveryone = isCheckerRole(currentUser);
@@ -457,6 +464,15 @@ public class VoucherVerificationApiResource {
final StringBuilder sql = new StringBuilder(""" final StringBuilder sql = new StringBuilder("""
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
-- The date the posting actually belongs to, which is not always the date the
-- voucher was written. A voucher's created_at is a wall-clock instant, while
-- the transaction behind it carries the business date — so a nightly run that
-- finishes after midnight stamps its vouchers with the next day although the
-- ledger sits on the day it worked on. Somebody reading this register today
-- is looking at last night's work, and needs to be told which day it is for.
COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) AS ledger_date,
(COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
< CAST(v.created_at AS DATE)) AS posted_after_close,
v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name, v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name,
ver.status AS verification_status, ver.status AS verification_status,
dr.party_name AS debit_party, dr.account_no AS debit_account, dr.party_name AS debit_party, dr.account_no AS debit_account,
@@ -465,6 +481,17 @@ public class VoucherVerificationApiResource {
cash.moved AS is_cash cash.moved AS is_cash
FROM m_voucher v FROM m_voucher v
LEFT JOIN m_office o ON o.id = v.branch_office_id LEFT JOIN m_office o ON o.id = v.branch_office_id
-- Carries the business date the posting belongs to, for ledger_date above.
--
-- Matched on either link. savings_txn_id is stamped by a second step after the
-- voucher is written, and on the interest register it is empty on every row --
-- reference_id already holds the same transaction id, put there when the voucher
-- was raised. Joining only on savings_txn_id left the ledger date blank on
-- precisely the postings this column exists 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_voucher_verification ver ON ver.voucher_no = v.voucher_no LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT party_name, account_no FROM m_voucher_line SELECT party_name, account_no FROM m_voucher_line
@@ -537,6 +564,38 @@ public class VoucherVerificationApiResource {
sql.append(" AND v.voucher_type = ?"); sql.append(" AND v.voucher_type = ?");
args.add(voucherType.trim()); args.add(voucherType.trim());
} }
// Narrowed to the people who hold a drawer.
//
// These two screens are the counter's own books: what came in over it and what went out.
// Everyone else's work — a clerk raising a request, a manager approving one, interest a
// nightly run posted — is their own to account for, and each person reads it on My Vouchers.
// Mixed in here it buried the cashier rows these registers exist to reconcile, and left the
// totals disagreeing with any drawer.
//
// Read from m_cashiers rather than from a role name, because holding a drawer is what makes
// somebody a cashier — a role called "Cashier" can be granted to anybody, and somebody
// covering a drawer without that role would otherwise vanish from the register they are
// answerable for. A username is matched through the staff record the drawer belongs to.
if (cashiersOnly) {
sql.append(" AND v.created_by_username IN (SELECT u.username FROM m_cashiers c"
+ " JOIN m_appuser u ON u.staff_id = c.staff_id WHERE u.username IS NOT NULL)");
}
// Postings nobody filed a request for: interest a nightly run credited, a charge collected
// from a balance, an escheat, a journal keyed straight to the ledger.
//
// Identified by what they are not — none of the five registers a person raises through. A
// reference type is stamped at post time and cannot be granted or revoked the way a role
// can, so this holds even when a job runs as somebody's own login rather than as `system`.
//
// These have no home on any other screen: My Vouchers is scoped to the reader's own work
// and a nightly job is nobody's, and the two counter registers deliberately exclude
// everything that did not cross a drawer. Without this filter the only way to see what the
// system posted was to read the whole register and discount the rest by eye.
if (systemOnly) {
sql.append(" 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'))");
}
// A cashier's register is cash: what came over the counter in notes and coins. // A cashier's register is cash: what came over the counter in notes and coins.
// //
// The test is whether a side of the voucher is the till (the `till` join above). That is // The test is whether a side of the voucher is the till (the `till` join above). That is
@@ -580,6 +639,11 @@ public class VoucherVerificationApiResource {
m.put("voucherType", r.get("voucher_type")); m.put("voucherType", r.get("voucher_type"));
m.put("amount", amount); m.put("amount", amount);
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
// The day the posting belongs to, and whether that differs from the day it was written.
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
// work: the register has to say so, or the reader takes the timestamp for the date.
m.put("ledgerDate", r.get("ledger_date") == null ? null : r.get("ledger_date").toString());
m.put("postedAfterClose", Boolean.TRUE.equals(r.get("posted_after_close")));
m.put("userId", r.get("created_by")); m.put("userId", r.get("created_by"));
m.put("username", r.get("created_by_username")); m.put("username", r.get("created_by_username"));
m.put("branchName", r.get("branch_name")); m.put("branchName", r.get("branch_name"));
@@ -607,12 +671,239 @@ public class VoucherVerificationApiResource {
// from the caller's roles a second time. // from the caller's roles a second time.
res.put("canSeeAllUsers", seesEveryone); res.put("canSeeAllUsers", seesEveryone);
res.put("cashOnly", restrictToCash); res.put("cashOnly", restrictToCash);
// So the screen can say whose register it is showing rather than leaving the reader to
// wonder why their own entries are absent from it.
res.put("cashiersOnly", cashiersOnly);
res.put("vouchers", out); res.put("vouchers", out);
res.put("count", out.size()); res.put("count", out.size());
res.put("totalAmount", total); res.put("totalAmount", total);
return gson.toJson(res); return gson.toJson(res);
} }
/**
* One user's day: everything they raised and everything they verified, receipts and payments
* together.
*
* <p>
* The Received and Payment registers each answer "what came in / went out today", across
* everyone. This answers the other question a branch asks at a close — "what did I do today" —
* and it cannot be assembled from those two, for two reasons. A checker's work leaves no
* voucher of their own: the slip stays in the maker's name, and the only trace of the approval
* is the verification row, which the registers do not read. And a person's day is one list, not
* a receipt list plus a payment list they have to reconcile in their head.
*
* <p>
* Each row therefore says which side it is (CR or DR, from the voucher's own series) and what
* the user's part in it was:
* <ul>
* <li>RAISED — they entered it;
* <li>VERIFIED — somebody else entered it, they checked it;
* <li>RAISED_AND_VERIFIED — both, which a small branch does legitimately (a manager posting and
* verifying their own entry), and which an auditor wants to see marked rather than hidden.
* </ul>
*
* <p>
* Always scoped to the caller, with no username parameter — a checker looking at another
* person's day uses the register with its user filter. This screen is "my work", and a
* parameter that let it be somebody else's would make its own heading a lie.
*/
@GET
@Path("my-work")
public String myWork(@QueryParam("from") final String from, @QueryParam("to") final String to) {
final AppUser currentUser = this.context.authenticatedUser();
final String username = currentUser.getUsername();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate fromDate = parseReportDate(from, today);
final LocalDate toDate = parseReportDate(to, today);
if (toDate.isBefore(fromDate)) {
throw new PlatformDataIntegrityException("error.msg.voucher.report.range.invalid",
"The end of the period cannot fall before its start.");
}
// A voucher qualifies on any of three counts, so the filter is an OR across them rather
// than three queries stitched together — that way a voucher the user both raised and
// actioned appears once, marked as both, instead of twice.
//
// "Approved" has two distinct meanings in this system and both belong here:
//
// - the maker-checker decision, held as manager_id/checker_id on the register's own row.
// This is the one an approver actually performs and the one they came looking for; it
// raises no voucher of its own, because the slip stays in the maker's name.
// - the physical cash check, held in m_voucher_verification, where somebody counted the
// notes against the slip.
//
// Reading only the second was the bug: a manager who approved a withdrawal all morning saw
// an empty page, because approving is not counting. The registers are unioned into one
// lookup keyed by voucher number, so each register's own approver column is read where it
// actually lives instead of being duplicated onto the voucher.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
WITH approvals AS (
SELECT voucher_no, manager_id AS approver_id, manager_name AS approver_name, actioned_at
FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
),
-- Who submitted the request the voucher belongs to.
--
-- Needed because m_voucher.created_by does not stay with the maker: when a cashier
-- hands the cash over, updateCashier() reassigns the voucher to them so it reaches
-- the cashier's own register. That reassignment overwrites rather than adds, so the
-- clerk who raised the request disappeared from their own page the moment it was
-- paid out — the register's maker column is the only surviving record of them.
makers AS (
SELECT voucher_no, clerk_id AS maker_id, status AS request_status
FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
)
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
v.created_by_username, o.name AS branch_name,
-- Where the voucher stands. The register's own status is what a person wants:
-- PENDING, APPROVED, WITHDRAWN, REJECTED — the life of the request they
-- raised or passed. The verification status is a different question (has
-- anybody counted the notes against the slip), answered on most days by
-- nobody, so showing only that left the column blank on every row.
COALESCE(mk.request_status, ver.status) AS verification_status,
-- Whoever actioned it, whichever way it was actioned. The maker-checker
-- decision is named first: it is the one a person is answerable for.
COALESCE(ap.approver_name, ver.verified_by_name) AS verified_by_name,
COALESCE(ap.actioned_at, ver.verified_at) AS verified_at,
dr.party_name AS debit_party, dr.account_no AS debit_account,
cr.party_name AS credit_party, cr.account_no AS credit_account,
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count,
(v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me,
(ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me,
-- Whether either side of the voucher is the till itself — same test as the
-- register listing below, and for the same reason: a Collection/Disbursement
-- request is numbered in the CR/DR series (it debits or credits a member's
-- own account), but when its other leg is a GL account rather than the vault,
-- no cash actually crossed the counter. Counting it under Received or Payment
-- read as double-counting the exact figure already shown on the Journal-free
-- day totals, when it was really a book entry between two ledgers.
till.touched AS till_touched
FROM m_voucher v
LEFT JOIN m_office o ON o.id = v.branch_office_id
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
LEFT JOIN approvals ap ON ap.voucher_no = v.voucher_no
LEFT JOIN makers mk ON mk.voucher_no = v.voucher_no
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 LATERAL (
SELECT TRUE AS touched FROM m_voucher_line cl
WHERE cl.voucher_no = v.voucher_no
AND (cl.gl_account_id IN (
SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa
WHERE fa.financial_activity_type IN (101, 102))
OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand'))
LIMIT 1) till ON TRUE
WHERE v.created_at >= ? AND v.created_at < ?
AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?)
ORDER BY v.created_at DESC, v.voucher_no DESC
""", username, currentUser.getId(), currentUser.getId(), currentUser.getId(),
Timestamp.valueOf(fromDate.atStartOfDay()), Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()),
username, currentUser.getId(), currentUser.getId(), currentUser.getId());
BigDecimal receivedTotal = BigDecimal.ZERO;
BigDecimal paymentTotal = BigDecimal.ZERO;
BigDecimal journalTotal = BigDecimal.ZERO;
int raisedCount = 0;
int verifiedCount = 0;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
final String voucherNo = (String) r.get("voucher_no");
final int lineCount = ((Number) r.get("line_count")).intValue();
// The series the number was issued in is the side: a register's own prefix is the one
// thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type
// would have to keep a list of types in step with the ones that issue them.
final String rawSide = voucherNo == null ? null
: voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV";
// A CR/DR voucher whose lines never touch the till (the same test bookBreakdown() uses
// below) is a book entry between two ledgers that happened to be numbered in the cash
// series — a Collection/Disbursement request posted against a GL account rather than the
// vault, e.g. a savings account debited straight into a Sundry GL account. Reading its
// prefix at face value counted that figure a second time, on top of whatever Journal
// already showed, as if real cash had crossed the counter. A voucher with no lines yet
// (raised but not actioned) is left alone — there is nothing to test the till against,
// and it is still real expected cash, not yet a book entry either way.
final boolean tillTouched = Boolean.TRUE.equals(r.get("till_touched"));
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
? "JV" : rawSide;
if (amount != null) {
if ("CR".equals(side)) {
receivedTotal = receivedTotal.add(amount);
} else if ("DR".equals(side)) {
paymentTotal = paymentTotal.add(amount);
} else {
journalTotal = journalTotal.add(amount);
}
}
final boolean raisedByMe = Boolean.TRUE.equals(r.get("raised_by_me"));
final boolean verifiedByMe = Boolean.TRUE.equals(r.get("verified_by_me"));
if (raisedByMe) {
raisedCount++;
}
if (verifiedByMe) {
verifiedCount++;
}
final Map<String, Object> m = new LinkedHashMap<>();
m.put("voucherNo", voucherNo);
m.put("side", side);
m.put("voucherType", r.get("voucher_type"));
m.put("amount", amount);
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
m.put("raisedBy", r.get("created_by_username"));
m.put("branchName", r.get("branch_name"));
m.put("verificationStatus", r.get("verification_status"));
m.put("verifiedByName", r.get("verified_by_name"));
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
m.put("debitParty", r.get("debit_party"));
m.put("debitAccount", r.get("debit_account"));
m.put("creditParty", r.get("credit_party"));
m.put("creditAccount", r.get("credit_account"));
m.put("extraLines", Math.max(0, lineCount - 2));
m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED");
out.add(m);
}
final Map<String, Object> res = new LinkedHashMap<>();
res.put("from", fromDate.toString());
res.put("to", toDate.toString());
res.put("username", username);
res.put("displayName", currentUser.getDisplayName());
res.put("vouchers", out);
res.put("count", out.size());
res.put("raisedCount", raisedCount);
res.put("verifiedCount", verifiedCount);
res.put("receivedTotal", receivedTotal);
res.put("paymentTotal", paymentTotal);
res.put("journalTotal", journalTotal);
return gson.toJson(res);
}
/** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */ /** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */
private static LocalDate parseReportDate(final String value, final LocalDate fallback) { private static LocalDate parseReportDate(final String value, final LocalDate fallback) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
@@ -24,11 +24,14 @@ import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
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.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
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.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -57,6 +60,8 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class VoucherService { public class VoucherService {
private static final Logger LOG = LoggerFactory.getLogger(VoucherService.class);
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL"; public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
public static final String TYPE_DEPOSIT = "DEPOSIT"; public static final String TYPE_DEPOSIT = "DEPOSIT";
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT"; public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
@@ -66,6 +71,8 @@ public class VoucherService {
public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST"; public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST";
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
/** A share redemption approved through the clerk register, keyed by the request id. */
public static final String REF_CLERK_SHARE_WITHDRAWAL_REQUEST = "CLERK_SHARE_WITHDRAWAL_REQUEST";
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
/** A voucher raised directly as a journal, rather than by one of the cash registers. */ /** A voucher raised directly as a journal, rather than by one of the cash registers. */
@@ -87,12 +94,20 @@ public class VoucherService {
*/ */
public static final String PREFIX_JOURNAL = "JV"; public static final String PREFIX_JOURNAL = "JV";
/** A charge or fee collected from an account. */
public static final String TYPE_CHARGE = "CHARGE";
/** An unclaimed balance moved to the escheat liability. */
public static final String TYPE_ESCHEAT = "ESCHEAT";
/** A loan disbursement or repayment, keyed by its m_loan_transaction id. */ /** A loan disbursement or repayment, keyed by its m_loan_transaction id. */
public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION"; public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION";
/** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */ /** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */
public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY"; public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY";
/** A savings movement, keyed by its m_savings_account_transaction id. */
public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -254,6 +269,120 @@ public class VoucherService {
return voucherNo; return voucherNo;
} }
/**
* Raises one voucher for one savings transaction, reading its sides from the journal entries the
* posting has already written.
*
* <p>
* This is how a branch register actually works: the slip is the transaction, not the leg. A
* deposit that also collects a charge posts three or four GL legs and gets ONE credit voucher
* carrying all of them, because that is what the member signed and what the teller files. Every
* register that builds its lines by hand risks drifting from the ledger it is supposed to
* mirror; this cannot, because it reads the ledger.
*
* <p>
* The series follows the transaction's own direction, the way a bank's voucher books are kept:
* <ul>
* <li>money in over the counter — a deposit — is a <b>credit voucher</b> (CR);
* <li>money out — a withdrawal, a charge collected in cash — is a <b>debit voucher</b> (DR);
* <li>anything that moves between accounts without cash crossing the counter — interest posted,
* a charge taken from the balance, a transfer, an escheat — is a <b>journal voucher</b> (JV).
* </ul>
* The caller passes the type and prefix because only the caller knows whether cash was involved:
* the same charge is a DR voucher at the counter and a JV when a batch job collects it.
*
* <p>
* Returns null, and raises nothing, when the transaction posted no journal entries — an on-hold
* or a release moves no money and has no voucher in a real branch either. A failure is logged
* rather than thrown: the transaction and its GL entries have already committed, and losing the
* slip is worth less than rolling a member's money back.
*
* @param savingsTxnId
* the m_savings_account_transaction id; its journal entries become the voucher lines
* @return the voucher number, or null if none was raised
*/
public String createVoucherFromJournalEntries(final String voucherType, final String referenceType, final Long savingsTxnId,
final AppUser user, final String prefix) {
if (savingsTxnId == null || !shouldRaiseVoucher()) {
return null;
}
try {
// Reversed legs are excluded: a correction posts its own reversing pair, and including
// both would net the voucher to zero and fail the balance check.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT je.type_enum, je.amount, je.account_id, gl.name AS gl_name, gl.gl_code,
je.description
FROM acc_gl_journal_entry je
JOIN acc_gl_account gl ON gl.id = je.account_id
WHERE je.savings_transaction_id = ?
AND (je.reversed IS NULL OR je.reversed = false)
ORDER BY je.type_enum DESC, je.id
""", savingsTxnId);
if (rows.isEmpty()) {
return null;
}
final Map<String, Object> party = savingsPartyForTransaction(savingsTxnId);
final String partyName = (String) party.get("party_name");
final String accountNo = (String) party.get("account_no");
final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue();
final List<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> row : rows) {
final Long glId = row.get("account_id") == null ? null : ((Number) row.get("account_id")).longValue();
final BigDecimal amount = (BigDecimal) row.get("amount");
// The member's own side is named after the member; every other leg is named after
// its GL account, which is what a voucher shows for an income or expense head.
final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId);
final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name");
final String ref = isMemberSide ? accountNo : (String) row.get("gl_code");
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT.
final int typeEnum = ((Number) row.get("type_enum")).intValue();
VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount)
: VoucherLine.creditGl(glId, name, ref, amount);
final String description = (String) row.get("description");
lines.add(description == null ? line : line.withDescription(description));
}
final String voucherNo = createVoucherWithLines(voucherType, referenceType, savingsTxnId, user, prefix, lines);
linkSavingsTransaction(referenceType, savingsTxnId, savingsTxnId);
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a voucher for savings transaction {}", savingsTxnId, e);
return null;
}
}
/** The voucher already raised against a savings transaction, if any. */
public String findVoucherNoForSavingsTransaction(final Long savingsTxnId) {
if (savingsTxnId == null) {
return null;
}
return jdbcTemplate
.queryForList("SELECT voucher_no FROM m_voucher WHERE savings_txn_id = ? ORDER BY id DESC LIMIT 1", String.class,
savingsTxnId)
.stream().findFirst().orElse(null);
}
/** The member and account a savings transaction belongs to, plus the product's control GL. */
private Map<String, Object> savingsPartyForTransaction(final Long savingsTxnId) {
return jdbcTemplate.queryForList("""
SELECT COALESCE(c.display_name, g.display_name) AS party_name,
sa.account_no,
apm.gl_account_id AS control_gl_id
FROM m_savings_account_transaction t
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 acc_product_mapping apm ON apm.product_id = sa.product_id
AND apm.product_type = 2 AND apm.financial_account_type = 2
WHERE t.id = ?
LIMIT 1
""", savingsTxnId).stream().findFirst().orElse(Map.of());
}
/** /**
* Checks that the lines form a voucher, and returns the value of one side. * Checks that the lines form a voucher, and returns the value of one side.
* *

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