Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:45:36 +05:30
nitesh a04b65583e fix(closure): post interest for real at submission, not as a dry-run estimate
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Ticking "Close this account" used to only preview the payout: assemble the
account, run the same postInterest domain call close() will, read the
resulting balance, then roll the whole thing back (GET /preview/{id}).
Nothing was ever credited until the manager approved, and the account's own
Interest Posted / Interest Earned figures stayed stale in the meantime.

Changed to actually post the interest at that moment (POST
/post-interest/{id} and again in submit()), via
SavingsAccountWritePlatformService#postInterest(SavingsAccount, boolean,
LocalDate, boolean) -- the exact service method close() itself calls, not a
new calculation. Not routed through the POSTINTEREST_SAVINGSACCOUNT command:
that command ships maker-checker-enabled in base Fineract, and
CommandSourceService#processCommand runs the handler first, then throws
RollbackTransactionNotApprovedException from inside the @Transactional
method -- which would silently roll back the very posting just made.

Depends on the sibling SavingsAccount fix (double-posting across runs) --
without it, ticking the checkbox on one business day and having the manager
approve on a later one re-slices posting periods and credits the overlapping
days twice.

Also:
- Locks the closure date at submission (stored on the request row) and
  replays that same date at approve() instead of re-reading today's business
  date, so close()'s own postInterest(account, true, closedOnDate, false)
  slices identical periods, matches the amounts already posted at submission,
  and adds nothing -- the figure quoted to the member at submission is what
  gets paid out however long the request sits in the manager's queue.
- Populates closing_amount/rounding_amount/cashier_disbursed_amount at
  submission time instead of leaving them null until approval, since the
  interest posting above already makes the real payout figure known then --
  the manager's review screen no longer shows a blank amount for a PENDING
  request.
2026-09-03 10:41:38 +05:30
nitesh f6686b0c8f fix(savings): stop manual interest postings from double-counting across runs
SavingsAccount#postInterest silently double-credited interest whenever a
manual posting ("Post Interest As On", or close() closing an account) ran on
two different dates against the same account.

Root cause: postInterestOnDate=D makes SavingsHelper#determineInterestPostingPeriods
close a period at D-1, so the resulting transaction is dated D-1, not D. The next
run's getManualPostingDates() feeds that D-1 date back in as a boundary, which
closes a period at D-2 instead. The old posting's date (D-1) then matches no
period end in the new run, so findInterestPostingTransactionFor() never finds
it, it is never reversed or corrected, and a new posting is created alongside
it covering the very same days. total_interest_posted_derived drifts above
what was actually earned by exactly the overlapping amount.

Reproduced live: posting "as on" 02 Sep then 03 Sep on the same account left
a stray 0.35 posting dated 01 Sep plus a new 0.97 posting for 01-02 Sep --
1.32 posted against 0.97 actually earned.

Fix: before applying this run's periods, reverse any manual posting whose
date matches no period-posting-date this run computed AND whose days are
covered by a period this run WILL post (posting date <= interestPostingUpToDate).
The second condition is deliberate -- it is what stops this from reversing a
posting whose days are simply not being re-posted yet (e.g. the recalculation
Fineract runs right after a withdrawal, where the remaining period matures in
the future and posts nothing for those days). Automatic (non-manual) postings
are untouched; they sit on natural period ends, which always recur correctly.

Affects every caller of the base postInterest/calculateInterestUsing pair,
including FixedDepositAccount and RecurringDepositAccount (both delegate to
super.postInterest for their day-to-day interest recalculation, though not
for postMaturityInterest, which is a separate, untouched method) -- so this
also closes the same double-count window for FD/RD accounts, not just plain
savings.
2026-09-03 10:41:14 +05:30
nitesh 45c2826a42 Revert "fix(savings,closure): post interest for real when a closure is checked"
This reverts commit c86e0d1cc2.
2026-09-02 18:31:45 +05:30
niteshandClaude Opus 5 c86e0d1cc2 fix(savings,closure): post interest for real when a closure is checked
Ticking "Close this account" previewed the payout with a dry run — assemble
the account, call the same postInterest domain method close() will, roll the
whole transaction back. The clerk asked for the actual thing: post the
interest now, the same as "Post Interest As On" always did and the same basis
FD uses at maturity, so the figure shown is the account's real balance rather
than a simulation of one.

Switched to calling SavingsAccountWritePlatformService#postInterest(SavingsAccount,
boolean, LocalDate, boolean) directly -- the exact method close() itself calls --
instead of going through the ordinary POSTINTEREST command pipeline. That matters:
POSTINTEREST_SAVINGSACCOUNT ships maker-checker-enabled in base Fineract, and a
Clerk is not a checker for it. Routed through commandsSourceWritePlatformService,
CommandSourceService#processCommand runs the handler first, then evaluates
maker-checker and throws RollbackTransactionNotApprovedException -- which, thrown
from inside that @Transactional method, rolls back everything the handler just
did and drops the request into Fineract's own generic Checker Inbox. This feature
already has its own maker-checker (submit here, approve in Closure Requests); the
posting now rides inside that instead of opening a second, unrelated approval
queue for the same click.

Left uncommitted-to on purpose: unticking the box or a later rejection leaves the
posting in place, since it is interest the member had genuinely earned either way.
Re-ticking re-runs postInterest safely -- the domain layer finds today's posting
already there and no-ops rather than posting twice. If manager approval lands on
a later business date, close() tops up the extra days on top; it never re-posts
what already matches the date it is closing on.

Endpoint changed GET -> POST to match: it now mutates. The dry-run machinery
(SavingsAccountAssembler kept, but PlatformTransactionManager/TransactionTemplate/
ConfigurationDomainService/MathContext all dropped) is gone with it -- the real
service method already handles all of that internally, the same way it does for
every other caller of postInterest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 17:29:14 +05:30
nitesh c052beeacf feat(branchday): reject day close if cashiers given cash are not reconciled or settled
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-02 12:07:24 +05:30
niteshandClaude Opus 5 942bad6c8c fix(branchday): count account closures in the day-close cash reconciliation
Day Close reported SHORT by exactly the amount a closure had paid out. The
"Cash paid out (approved withdrawals)" line only summed
m_clerk_withdrawal_request, so a closure -- which pays cash over the same
counter but lives in its own register -- was missing from the total. Expected
cash stayed high by what had actually been handed over, and a closure that
balanced perfectly read as a shortage.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:58:12 +05:30
niteshandClaude Opus 5 5a9a103832 Merge branch 'maker-checker' into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Brings in maker-checker for GL accounts, charges, product creation (loan,
savings, current, share, FD), admin users/roles and base interest rates, plus
the UserPendingRequest domain and AppUserPreference.

Two resolutions:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:06:57 +05:30
niteshandClaude Sonnet 5 1130a482a0 feat(shares): centralized Additional Shares approval queue, remove unused Share Savings Debit
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
- Add ShareAdditionRequestsApiResource (/v1/shareadditionrequests): lists every pending
  additional-share tranche across all accounts, with a per-tranche detail view carrying the
  route-specific proof of funding a list row can't show (which voucher(s) fund a cash tranche, or
  which savings account a savings tranche already debited). Approval/rejection still go through
  Fineract's own approveadditionalshares/rejectadditionalshares commands unchanged -- this is a read
  side only, so the existing settle()/unwind() hooks fire from exactly one code path.
- Remove ShareSavingsDebitRequestApiResource and its m_share_savings_debit_request table (migration
  0520): confirmed zero rows on both tenants since it was built, and superseded by Additional Shares'
  own savings-funding route, which values a tranche on its own rather than by summing the account's
  entire holding history.
- Update comments in ShareAccountWritePlatformServiceJpaRepositoryImpl, ShareAccountDataSerializer,
  AdditionalShareTrancheFundingService and AdditionalSharesFundingApiResource that referenced the
  removed class/endpoint.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:41:42 +05:30
anshul 08519da8b1 feat: implement maker-checker workflow for user creation and update requests
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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-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-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-27 17:51:22 +05:30
niteshandClaude Sonnet 5 58187c23d0 fix(migrations): remove fake test-data seeds, backfill lost permissions, merge redundant roles
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Migration-hygiene fixes found while auditing whether a fresh database would
end up with the same schema/permissions/roles as this dev database.

1. Remove fake test-data seed migrations

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

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

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

3. Merge two pairs of redundant roles

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

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

1. FD Special account creation/modification auto-configuration

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 19:02:17 +05:30
niteshandClaude Sonnet 5 a42a874811 fix(deposits): stop FD Special interest postings from self-correcting after settlement
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
FD Special (monthly interest payout) accounts post interest each month and
immediately sweep it to a linked savings account. Two independent interest
recalculation paths were treating that sweep withdrawal as ordinary account
activity and silently reversing + reposting already-settled periods at a
slightly different (rounding-drifted) amount — after the original, correct
amount had already been transferred out. This left the FD short by the
difference every time it happened.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:57:02 +05:30
niteshandClaude Sonnet 5 eb1aa767f7 fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:04:23 +05:30
nitesh 8724c54d9b 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract 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 (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 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/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
2026-08-19 10:50:25 +05:30
nitesh 5c0a482f49 feat(kyc): resolve DIP/MNRL demo verdicts locally, make the check toggleable
Every mobile-number lookup was calling out to a local mock HTTP service
on port 8090 that had to be started separately, so the check failed
with a connection error whenever that process wasn't running.

Ports the mock's full verdict table (suspected, disconnected,
suspected+disconnected, clean) into MobileLookupServiceImpl itself, so
the demo/test numbers resolve in-process with no HTTP call. Adds
fineract.mobile-lookup.enabled (default off) to skip the real DIP/MNRL
call entirely for local work; every number then resolves CLEAN and is
stamped LOOKUP_DISABLED_BY_CONFIG in the log so it's never mistaken
for a genuine pass. Must be turned back on before UAT/production.
2026-08-19 10:35:48 +05:30
nitesh 0e07e60328 feat(current-accounts): add document upload/list/download/delete endpoints
/v1/currentaccounts/{id}/documents had no handler at all -- the
account's own /v1/currentaccounts resource matches the request path
first and has no documents method, so it never fell through to the
generic /v1/{entityType}/{entityId}/documents resource that every
other entity type relies on. Current accounts got a 404 on every
Documents-tab request.

Delegates straight to the existing DocumentApiResource so current
accounts get identical behaviour to clients, loans, etc.
2026-08-19 10:35:35 +05:30
nitesh e227e8ae0f fix(savings): post GL entries for facility-charge collections at edit approval
Charges collected when a maker-checker Nominee/Digital-Feature edit is
approved deducted the account balance and appeared in the passbook,
but never posted a journal entry -- collectCharges() saved the charge
transaction directly and skipped the accounting bridge entirely, so
the GL never balanced against the subledger.

Also fixes an unrelated NPE this exposed: collectCharges() used
saveAll() instead of saveAllAndFlush(), so the new charge transaction
had no id yet when the journal-entry code tried to read it.
2026-08-19 10:35:18 +05:30
nitesh 231919b62b fix(savings): stop crediting accounts automatically on activation
Activation for Savings, RD, FD and Current accounts no longer books an
opening deposit or enforces a minimum opening balance -- accounts go
active at zero balance and a clerk deposits funds afterward as a
separate transaction, matching how these branches actually operate.

Fixed Deposit and Recurring Deposit have their own override of
activateWithBalance() that a prior fix to the shared SavingsAccount
base class missed, so they kept auto-depositing after Savings was
already fixed. Removing that auto-deposit exposed two follow-on bugs
in the FD/RD activation flow, now also fixed:
- the opening deposit's journal entries were posted twice (once by the
  deposit helper, once by the activation command's own posting call)
- FD/RD maturity amount was computed from the account's real (now
  empty) transaction history at activation and never recalculated once
  a deposit actually landed, so it showed no projected interest until
  the account was reloaded from a lucky code path
2026-08-19 10:34:54 +05:30
431 changed files with 46884 additions and 8248 deletions
+68
View File
@@ -0,0 +1,68 @@
-- Removes the duplicate vouchers left behind by the double-count bug.
--
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
-- and Payment reports counted both, doubling the day's cash.
--
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
--
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
-- that link and orphan the request row.
--
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
BEGIN;
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
-- genuinely separate transactions of the same value on the same day are never confused.
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
SELECT sav.voucher_no
FROM m_voucher sav
JOIN m_voucher reg
ON reg.savings_txn_id = sav.reference_id
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
-- rather than removed silently.
DO $$
DECLARE
blocked INT;
BEGIN
SELECT COUNT(*) INTO blocked
FROM (SELECT voucher_no FROM m_voucher_verification
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
UNION ALL
SELECT voucher_no FROM m_voucher_attachment
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
IF blocked > 0 THEN
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
END IF;
END $$;
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
DO $$
DECLARE
stamped INT;
BEGIN
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
IF stamped > 0 THEN
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
END IF;
END $$;
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
COMMIT;
@@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
INSERT IGNORE INTO m_charge
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
charge_calculation_enum, is_penalty, is_active, is_deleted,
is_free_withdrawal, is_payment_type, income_or_liability_account_id)
SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0,
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0);
INSERT IGNORE INTO m_charge
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
charge_calculation_enum, is_penalty, is_active, is_deleted,
@@ -168,6 +160,5 @@ VALUES
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)),
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
-4
View File
@@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
@@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
@@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
@@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
*/
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
/**
* 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 paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
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());
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
element);
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
}
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
GL_ACCOUNT_DISABLED, //
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
GL_ACCOUNT_IS_HEADER, //
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
public String errorMessage() {
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "Invalid debit or credit accounts are passed";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
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();
}
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_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();
}
@@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
.notNull().integerGreaterThanZero();
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
}
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
@@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
}
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
final Long overdraftAccount = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
.value(overdraftAccount).notNull().integerGreaterThanZero();
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
// only, so they are validated when supplied rather than demanded from every savings product.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
element)) {
final Long overdraftAccount = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
.value(overdraftAccount).notNull().integerGreaterThanZero();
}
final Long incomeFromInterest = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
.notNull().integerGreaterThanZero();
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
final Long incomeFromInterest = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
.value(incomeFromInterest).notNull().integerGreaterThanZero();
}
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
.integerGreaterThanZero();
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
final Long writtenOff = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
.notNull().integerGreaterThanZero();
}
}
}
@@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
// Expense
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
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);
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) {
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
// Expense
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
@@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
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 ***/
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);
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
break;
case ACCRUAL_PERIODIC:
// asset
@@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
break;
case ACCRUAL_UPFRONT:
@@ -269,7 +269,15 @@ public final class AccountingConstants {
OVERDRAFT_PORTFOLIO_CONTROL(11), //
INCOME_FROM_INTEREST(12), //
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;
@@ -317,7 +325,9 @@ public final class AccountingConstants {
FEES_RECEIVABLE(15), //
PENALTIES_RECEIVABLE(16), //
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;
@@ -372,7 +382,8 @@ public final class AccountingConstants {
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
FEES_RECEIVABLE("feesReceivableAccountId"), //
INTEREST_PAYABLE("interestPayableAccountId");
INTEREST_PAYABLE("interestPayableAccountId"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
private final String value;
@@ -410,7 +421,8 @@ public final class AccountingConstants {
FEES_RECEIVABLE("feeReceivableAccount"), //
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
INTEREST_PAYABLE("interestPayableAccount"), //
INTEREST_RECEIVABLE("interestReceivableAccount"); //
INTEREST_RECEIVABLE("interestReceivableAccount"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
private final String value;
@@ -437,8 +449,34 @@ public final class AccountingConstants {
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
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 String code;
@@ -137,7 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || user.isCheckerSuperUser()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,6 +151,25 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -664,6 +664,14 @@ public class CommandWrapperBuilder {
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() {
this.actionName = "CREATE";
this.entityName = "OFFICETRANSACTION";
@@ -2211,6 +2219,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createCbsBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSBASEINTERESTRATE";
this.entityId = null;
this.href = "/fixeddepositproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -2897,6 +2913,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
this.actionName = "PAYMENT";
this.entityName = "FIXEDDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
return this;
}
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
this.actionName = "CALCULATEINTEREST";
this.entityName = "FIXEDDEPOSITACCOUNT";
@@ -3103,6 +3128,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
this.actionName = "UPDATE";
this.entityName = "FACILITYCHARGEMAPPING";
this.entityId = mappingId;
this.href = "/facilitychargemappings/" + mappingId;
return this;
}
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
this.actionName = "DELETE";
this.entityName = "FINANCIALACTIVITYACCOUNT";
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
boolean isMakerCheckerEnabled();
/**
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -39,7 +39,10 @@ public enum JobName {
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -53,6 +56,7 @@ public enum JobName {
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business 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_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
public void delete(final Office 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();
}
}
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
// differentiate deposit accounts Savings, FD and RD accounts
private final EnumOptionData depositType;
/**
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
* for an FD that has never been renewed, and is null for non-deposit accounts.
*/
private final String fdAccountNumber;
/**
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
@Column(name = "is_active", nullable = false)
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() {}
/**
@@ -162,6 +162,15 @@ public class ClientApiConstants {
public static final String ageDaysParamName = "ageDays";
public static final String nationalityIdParamName = "nationalityId";
public static final String isMinorParamName = "isMinor";
/**
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
* list (with mandatory PAN) for a client the UI classifies as a minor.
*/
public static final int MINOR_AGE_THRESHOLD = 10;
/** At or above this age a client qualifies for senior-citizen deposit rates. */
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
public static final String guardianClientIdParamName = "guardianClientId";
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
public static final String guardianRelationParamName = "guardianRelation";
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant();
this.initiatedOn = DateUtils.getBusinessLocalDate();
}
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
@@ -484,6 +484,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory);
this.riskCategory = newValue;
// The re-KYC cycle is a property of the risk category, so moving the category moves the
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
// renewal and is never called back in for the two-yearly review the rating requires.
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
if (newExpiry != null) {
actualChanges.put("expiryDate", newExpiry);
}
}
// remarks is the audit justification itself, so a change to it is deliberately not recorded
@@ -554,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
// The four exception columns are derived from whether the identity checks have passed, not
// data an officer types, so they are updated without recording an overwrite. A verified
// record demands remarks for any field it already held (see
// 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, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
@@ -612,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
* populated is the record being completed rather than altered, so it is deliberately not
* 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) {
final boolean wasPopulated = currentValue instanceof String text
? StringUtils.isNotBlank(text)
@@ -662,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markCkycUploaded(final String ckycNumber, final String batchId) {
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, 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. */
public void markVerified(final String ckycNumber) {
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
this.verifiedOn = DateUtils.getLocalDateOfTenant();
this.verifiedOn = DateUtils.getBusinessLocalDate();
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
@@ -677,17 +734,45 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
}
}
/**
* Re-dates the re-KYC due date after the risk category moves.
*
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
* High 2), and it is counted from the day of the change rather than from the original
* verification. A customer moved to High in year two of a Low rating is then due in two years'
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
* an overdue queue the moment a re-rating ran.
*
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
* {@link #markVerified} sets the date when it is first verified.
*
* @return the new expiry date, or {@code null} if the record carries no expiry
*/
private LocalDate recalculateExpiryForRiskCategory() {
if (this.verifiedOn == null) {
return null;
}
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
if (risk.getReKycYears() <= 0) {
return this.expiryDate;
}
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
return this.expiryDate;
}
/** Called when verification fails. */
public void markRejected(final String remarks) {
this.kycStatus = ClientKycStatus.REJECTED.getValue();
this.rejectedOn = DateUtils.getLocalDateOfTenant();
this.rejectedOn = DateUtils.getBusinessLocalDate();
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
}
public void markPanVerified(final String fullName, final String firstName, final String lastName,
final String aadhaarSeedingStatus, final String category, final String status) {
this.panVerified = true;
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
@@ -713,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markAadhaarVerified() {
this.aadhaarVerified = true;
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markGstinVerified() {
this.gstinVerified = true;
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markCinVerified() {
this.cinVerified = true;
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markDinVerified() {
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
this.dinNumber = dinNumber;
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
this.isDirector = true;
this.dinMcaResponse = mcaResponseJson;
}
@@ -755,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else if (type != null && type.isNonIndividual()) {
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);
}
return allDone;
@@ -798,6 +893,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
*/
public void escalateRiskCategory(Integer newCategory) {
this.riskCategory = newCategory;
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
recalculateExpiryForRiskCategory();
}
public String getPanNumber() {
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.client.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
/**
* KYC records whose periodic re-KYC due date has passed, oldest first.
*
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
* never took effect, and restricting an account over it would punish the wrong thing.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
@@ -93,6 +93,26 @@ public class ClientRiskRule {
@Column(name = "order_position", nullable = false)
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() {}
/**
@@ -104,8 +124,9 @@ public class ClientRiskRule {
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
final String dormantFlag) {
final String dormantFlag, final String ruleKind) {
final ClientRiskRule rule = new ClientRiskRule();
rule.ruleKind = ruleKind;
rule.ruleName = ruleName;
rule.reason = reason;
rule.riskCategory = riskCategory;
@@ -137,6 +158,19 @@ public class ClientRiskRule {
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() {
return ruleName;
}
@@ -0,0 +1,106 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/**
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
*
* <p>
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
* a manual decision with its own notice.
*
* <p>
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
* notice" without fixing a number — so they are declared here once and read from here everywhere.
*/
public enum KycExpiryStage {
/**
* Inside the grace window. Nothing is restricted; the customer is being chased.
*/
GRACE(0),
/**
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
* the customer into the branch without putting their incoming money at risk.
*/
PARTIAL_FREEZE(90),
/**
* Both directions restricted, barring the credits that must always be let through.
*/
INOPERATIVE(180);
/** Days past the expiry date at which this stage begins. */
private final int fromDaysOverdue;
KycExpiryStage(final int fromDaysOverdue) {
this.fromDaysOverdue = fromDaysOverdue;
}
public int getFromDaysOverdue() {
return fromDaysOverdue;
}
/**
* The stage an account sits at, given how far past its re-KYC due date it is.
*
* @param daysOverdue
* days since the expiry date; zero or negative means the KYC has not lapsed
* @return the stage, or {@code null} where the KYC is still current and nothing applies
*/
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
if (daysOverdue < 0) {
return null;
}
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
return INOPERATIVE;
}
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
return PARTIAL_FREEZE;
}
return GRACE;
}
public boolean isGrace() {
return this == GRACE;
}
public boolean isPartialFreeze() {
return this == PARTIAL_FREEZE;
}
public boolean isInoperative() {
return this == INOPERATIVE;
}
/** Whether debits should be stopped at this stage. */
public boolean blocksDebits() {
return this == PARTIAL_FREEZE || this == INOPERATIVE;
}
/** Whether ordinary credits should be stopped at this stage. */
public boolean blocksCredits() {
return this == INOPERATIVE;
}
}
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
private final Integer value;
private final String code;
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
case 200 -> TRANSFER_TO_SAVINGS;
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
default -> INVALID;
};
}
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
return this.equals(INVALID);
}
public boolean isPartialReinvest() {
return this.equals(PARTIAL_REINVEST);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
fixedDepositResponseData.add("projectedInterestSchedule");
fixedDepositResponseData.add("totalProjectedInterest");
fixedDepositResponseData.add("totalValueAtMaturity");
return fixedDepositResponseData;
}
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
public static final String statusParamName = "status";
public static final String subStatusParamName = "subStatus";
public static final String reasonForBlockParamName = "reasonForBlock";
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
public static final String holdCategoryParamName = "holdCategory";
public static final String clientIdParamName = "clientId";
public static final String isRetailAccountParamName = "isRetailAccount";
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
public static final String endDateParamName = "endDate";
public static final String introducerRequiredParamName = "introducerRequired";
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
public static final String shareAccountIdParamName = "shareAccountId";
public static final String nominationFacilityParamName = "nominationFacility";
public static final String dormancyAlertParamName = "dormancyAlert";
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
@@ -254,7 +258,6 @@ public class SavingsApiConstants {
public static final String neftFacilityParamName = "neftFacility";
public static final String rtgsFacilityParamName = "rtgsFacility";
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
public static final String withdrawalFacilityParamName = "withdrawalFacility";
public static final String depositFacilityKycParamName = "depositFacilityKyc";
public static final String virtualDebitCardParamName = "virtualDebitCard";
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
private LocalDate endDate;
private Boolean introducerRequired;
private Boolean shareLinkingEnabled;
private Long shareAccountId;
private String shareAccountNo;
private Boolean nominationFacility;
private Boolean dormancyAlert;
private Boolean isDormancyTrackingActiveFromProduct;
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
private BigDecimal interestPosting;
private BigDecimal overdraftPosting;
/**
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
* Such interest never stays in the account, so it must never compound -- every period earns on the
* principal alone. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean transferInterestToLinkedAccount;
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
}
public boolean isTransferInterestToLinkedAccount() {
return this.transferInterestToLinkedAccount;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -256,7 +256,16 @@ public final class PostingPeriod {
this.daysInYear = daysInYear;
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();
} else {
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
break;
case PARTIAL_REINVEST:
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
}
return optionData;
}
@@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility", nullable = false)
private boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility", nullable = false)
private boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc", nullable = false)
private boolean depositFacilityKyc = false;
@@ -120,7 +117,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
@@ -136,9 +136,6 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@@ -378,7 +378,6 @@ public class CurrentProductAssembler {
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
return config;
@@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc = false;
@@ -125,8 +122,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
@@ -147,7 +142,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
this.neftFacility = other.getNeftFacility();
this.rtgsFacility = other.getRtgsFacility();
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
this.withdrawalFacility = other.getWithdrawalFacility();
this.depositFacilityKyc = other.getDepositFacilityKyc();
this.virtualDebitCard = other.getVirtualDebitCard();
}
@@ -117,7 +117,6 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
product.setAccountFacilityConfig(facilityConfigMap);
@@ -0,0 +1,243 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.balancesheet.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* An "as on date" balance sheet built directly off the live chart of accounts and journal entries,
* rather than a stock Fineract trial balance report — this reproduces the specific sub-group-wise
* layout this bank's own paper balance sheet uses: every top-level Header account (Capital, Deposit
* Account, Other Liabilities, ...) is one named block, with its Detail (and any nested Header)
* children indented underneath it and a subtotal on the block itself. A Detail account with no
* Header at all (see GLAccountTransactionGuard's sibling concern - some accounts are seeded standalone)
* still needs to appear somewhere, so it is listed as its own root-level line rather than silently
* dropped.
*
* A Header account never itself carries a journal entry (see the header/detail discussion this
* feature grew out of), so its balance is always the recursive sum of its children - never read off
* acc_gl_journal_entry directly.
*/
@Path("/v1/accounting/balancesheet")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BalanceSheetApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "GLACCOUNT";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
// classification_enum values (see acc_gl_account / GLAccountType).
private static final int ASSET = 1;
private static final int LIABILITY = 2;
private static final int EQUITY = 3;
private static final int INCOME = 4;
private static final int EXPENSE = 5;
// account_usage values (see GLAccountUsage).
private static final int DETAIL = 1;
private static final int HEADER = 2;
private static final class Node {
Long id;
String name;
String glCode;
int usage;
int classification;
Long parentId;
BigDecimal ownBalance = BigDecimal.ZERO;
final List<Node> children = new ArrayList<>();
BigDecimal balance() {
if (usage == DETAIL) {
return ownBalance;
}
BigDecimal sum = BigDecimal.ZERO;
for (final Node child : children) {
sum = sum.add(child.balance());
}
return sum;
}
Map<String, Object> toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("id", id);
json.put("name", name);
json.put("glCode", glCode);
json.put("usage", usage == HEADER ? "HEADER" : "DETAIL");
json.put("balance", balance());
final List<Map<String, Object>> childJson = new ArrayList<>();
for (final Node child : children) {
childJson.add(child.toJson());
}
json.put("children", childJson);
return json;
}
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
public String retrieveBalanceSheet(@QueryParam("asOnDate") final String asOnDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate asOnDate = (asOnDateParam == null || asOnDateParam.isBlank()) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(asOnDateParam);
final Map<Long, Node> nodesById = new LinkedHashMap<>();
for (final Map<String, Object> row : this.jdbcTemplate.queryForList(
"SELECT id, name, gl_code, parent_id, account_usage, classification_enum FROM acc_gl_account "
+ "WHERE disabled = false ORDER BY gl_code")) {
final Node node = new Node();
node.id = ((Number) row.get("id")).longValue();
node.name = (String) row.get("name");
node.glCode = (String) row.get("gl_code");
node.parentId = row.get("parent_id") == null ? null : ((Number) row.get("parent_id")).longValue();
node.usage = ((Number) row.get("account_usage")).intValue();
node.classification = ((Number) row.get("classification_enum")).intValue();
nodesById.put(node.id, node);
}
// entry_date (not transaction_date) and "reversed = false" are this codebase's settled
// convention for reading balances off the ledger - ProfitAndLossApiResource, the dividend
// reports and IncomeProfitTransferService all cut the same way, so this report agrees with
// them rather than quietly producing a second set of numbers.
//
// The reversal_id exclusion covers a case that plain "reversed = false" gets wrong. This
// codebase reverses in two different ways: the clerk/sponsor-DD undo paths just flip the
// flag on the original leg (no counter entry - excluding it is exactly right), while
// Fineract's own revertJournalEntry additionally posts an opposite-sign counter entry and
// links it back through reversal_id. For that second kind, dropping only the original would
// leave its counter entry standing alone and swing the balance by the full amount in the
// wrong direction, so the counter entry has to go too.
final Map<Long, BigDecimal[]> balances = new LinkedHashMap<>();
final String balanceSql = "SELECT account_id, "
+ "COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, "
+ "COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit "
+ "FROM acc_gl_journal_entry WHERE entry_date <= ? "
+ "AND (reversed IS NULL OR reversed = false) "
+ "AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)"
+ (officeId != null ? " AND office_id = ?" : "") + " GROUP BY account_id";
final List<Map<String, Object>> balanceRows = officeId != null
? this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate), officeId)
: this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate));
for (final Map<String, Object> row : balanceRows) {
balances.put(((Number) row.get("account_id")).longValue(),
new BigDecimal[] { (BigDecimal) row.get("total_debit"), (BigDecimal) row.get("total_credit") });
}
for (final Node node : nodesById.values()) {
if (node.usage != DETAIL) {
continue;
}
final BigDecimal[] debitCredit = balances.get(node.id);
if (debitCredit == null) {
continue;
}
final boolean debitNatured = node.classification == ASSET || node.classification == EXPENSE;
node.ownBalance = debitNatured ? debitCredit[0].subtract(debitCredit[1]) : debitCredit[1].subtract(debitCredit[0]);
}
final List<Node> roots = new ArrayList<>();
for (final Node node : nodesById.values()) {
if (node.parentId == null) {
roots.add(node);
} else {
final Node parent = nodesById.get(node.parentId);
if (parent != null) {
parent.children.add(node);
} else {
// Parent was disabled (filtered out above) - surface this node at the top level
// rather than silently losing its balance from the report.
roots.add(node);
}
}
}
final Map<String, Object> debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE);
final Map<String, Object> creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOnDate.toString());
response.put("officeId", officeId);
response.put("debitSide", debitSide);
response.put("creditSide", creditSide);
return this.gson.toJson(response);
}
private Map<String, Object> buildSide(final String label, final List<Node> roots, final int... classifications) {
final List<Map<String, Object>> groups = new ArrayList<>();
BigDecimal sideTotal = BigDecimal.ZERO;
for (final int classification : classifications) {
final List<Map<String, Object>> nodeJsons = new ArrayList<>();
BigDecimal groupTotal = BigDecimal.ZERO;
for (final Node root : roots) {
if (root.classification != classification) {
continue;
}
nodeJsons.add(root.toJson());
groupTotal = groupTotal.add(root.balance());
}
if (!nodeJsons.isEmpty()) {
final Map<String, Object> group = new LinkedHashMap<>();
group.put("classification", classificationLabel(classification));
group.put("total", groupTotal);
group.put("nodes", nodeJsons);
groups.add(group);
sideTotal = sideTotal.add(groupTotal);
}
}
final Map<String, Object> side = new LinkedHashMap<>();
side.put("label", label);
side.put("total", sideTotal);
side.put("groups", groups);
return side;
}
private String classificationLabel(final int classification) {
return switch (classification) {
case ASSET -> "ASSET";
case LIABILITY -> "LIABILITY";
case EQUITY -> "EQUITY";
case INCOME -> "INCOME";
case EXPENSE -> "EXPENSE";
default -> "OTHER";
};
}
}
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.common;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator
* configures.
*
* <p>
* These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread
* across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend
* journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from
* the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one
* screen.
*
* <p>
* Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made
* the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws
* rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to
* configure, instead of leaving a declaration that appears to have worked but moved nothing.
*/
@Service
@RequiredArgsConstructor
public class DividendGlAccountResolver {
private final JdbcTemplate jdbcTemplate;
/** The year's closed-out profit a dividend is declared against. */
public Long distributableProfitAccount() {
return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT);
}
/** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */
public Long retainedProfitAccount() {
return requireMapped(FinancialActivity.RETAINED_PROFIT);
}
/** Equity the declared profit is set aside in, between declaration and board approval. */
public Long reserveForDividendAccount() {
return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND);
}
/** The liability to members, created on approval and discharged as each member claims. */
public Long payableDividendsAccount() {
return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS);
}
/**
* Where the paise a dividend cannot divide evenly are swept.
*
* Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed
* - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets
* the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than
* a decision the society has failed to make, which is why it resolves rather than throwing.
*/
public Long dividendRoundOffAccount() {
final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF);
return roundOff != null ? roundOff : retainedProfitAccount();
}
/**
* Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure.
*
* Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend
* Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it
* returns null instead of throwing.
*/
public Long dividendPaidAccountOrNull() {
return mappedAccount(FinancialActivity.DIVIDEND_PAID);
}
/** The GL mapped to an activity, or null when the society has not configured it. */
public Long mappedAccount(final FinancialActivity activity) {
return this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
activity.getValue())
.stream().findFirst().orElse(null);
}
private Long requireMapped(final FinancialActivity activity) {
final Long glAccountId = mappedAccount(activity);
if (glAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured",
"No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity "
+ "Mappings before running the dividend cycle.",
activity.getValue());
}
return glAccountId;
}
}
@@ -0,0 +1,185 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
* how much is still unclaimed, and how much has come back to profit.
*
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
* money moved; the locks are what it moved for.
*
* The columns mirror the money's actual path, which begins and ends in the same place:
*
* <pre>
* declare Dr Bank Profit Cr Dividend Payable
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
* </pre>
*
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
* is what is still sitting unclaimed and still within its three-year window.
*/
@Path("/v1/accounting/dividend-yearwise-report")
@Component
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
@RequiredArgsConstructor
public class DividendYearwiseReportApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String report() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
// through the per-member detail rather than counted off the declaration, because a lock is only
// created for a member who actually had something to collect.
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
// Summing the declaration amount across a join to its locks would multiply it by the number
// of members it was split between.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
WITH fy AS (
SELECT d.id,
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
d.amount
FROM m_share_product_dividend_pay_out d
),
declared AS (
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
FROM fy GROUP BY fy_start
),
outcomes AS (
SELECT fy.fy_start,
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
FROM fy
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
GROUP BY fy.fy_start
)
SELECT d.fy_start, d.declaration_count, d.declared_amount,
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
o.earliest_release_due
FROM declared d
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
ORDER BY d.fy_start DESC
""");
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
// member, so no lock was ever created for it. Pulled separately and matched by year.
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
THEN EXTRACT(YEAR FROM period_end_date)
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
COALESCE(SUM(residue_amount), 0) AS residue
FROM m_share_dividend_rounding_residue
GROUP BY fy_start
""")) {
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
}
final List<Map<String, Object>> years = new ArrayList<>();
BigDecimal totalDeclared = BigDecimal.ZERO;
BigDecimal totalClaimed = BigDecimal.ZERO;
BigDecimal totalUnclaimed = BigDecimal.ZERO;
BigDecimal totalForfeited = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("financialYear", financialYear);
entry.put("declarationCount", row.get("declaration_count"));
entry.put("declaredAmount", declared);
entry.put("claimedAmount", claimed);
entry.put("claimedCount", row.get("claimed_count"));
entry.put("unclaimedAmount", unclaimed);
entry.put("unclaimedCount", row.get("unclaimed_count"));
entry.put("forfeitedAmount", forfeited);
entry.put("forfeitedCount", row.get("forfeited_count"));
entry.put("roundingResidueAmount", residue);
// What has already gone back to profit for this year: forfeited entitlements plus the
// rounding residue swept at declaration time.
entry.put("returnedToProfitAmount", forfeited.add(residue));
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
// amount starts returning to profit.
entry.put("earliestReleaseDueDate",
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
years.add(entry);
totalDeclared = totalDeclared.add(declared);
totalClaimed = totalClaimed.add(claimed);
totalUnclaimed = totalUnclaimed.add(unclaimed);
totalForfeited = totalForfeited.add(forfeited);
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("years", years);
response.put("totalDeclared", totalDeclared);
response.put("totalClaimed", totalClaimed);
response.put("totalUnclaimed", totalUnclaimed);
response.put("totalForfeited", totalForfeited);
return gson.toJson(response);
}
private String financialYearLabel(final int startYear) {
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
}
@@ -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.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
import org.apache.fineract.accounting.common.AccountingConstants;
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.CashAccountsForSavings;
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.shareaccounts.data.ShareAccountTransactionEnumData;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@RequiredArgsConstructor
public class AccountingProcessorHelper {
@@ -89,6 +92,14 @@ public class AccountingProcessorHelper {
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
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);
public static void setDepositAccountTypeContext(int depositAccountType) {
@@ -116,6 +127,10 @@ public class AccountingProcessorHelper {
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
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(
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
@@ -600,6 +615,144 @@ public class AccountingProcessorHelper {
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),
* 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();
}
/**
* 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,
final Long paymentTypeId) {
GLAccount glAccount;
@@ -1263,6 +1439,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
// Routed through the payable and paid accounts the product names, so the society
// can read interest declared but not yet credited as its own balance. Collapses
// 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,9 +21,11 @@ package org.apache.fineract.accounting.journalentry.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -104,6 +106,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.NonTransientDataAccessException;
@@ -136,6 +140,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
private final LoanTransactionRepository loanTransactionRepository;
private final VoucherService voucherService;
@Transactional
@Override
@@ -232,14 +237,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
}
// Raise the voucher from the entries that were actually written, rather than from the
// command: an accounting rule can supply a side the request never named, so reading
// back by transaction id is the only view that matches what the ledger now holds.
final String voucherNo = raiseJournalVoucher(transactionId);
if (voucherNo != null) {
changes.put("voucherNo", voucherNo);
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
.withTransactionId(transactionId).build();
.withTransactionId(transactionId).with(changes).build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
final Throwable throwable = dve.getMostSpecificCause();
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
}
}
/**
* Records a manual journal in the voucher register and returns its number.
*
* The sides are read back from the entries just written rather than taken from the request,
* because an accounting rule can contribute a debit or credit the caller never named; only the
* persisted rows show what the ledger actually holds.
*
* A failure here does not fail the journal. The entries are already posted and correct, and
* refusing them because the register could not be written would reject a sound accounting
* transaction over its paperwork. The problem is logged and the journal stands without a
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
*/
private String raiseJournalVoucher(final String transactionId) {
// A register that posts its ledger movement through this command raises its own voucher,
// naming the member and the instrument. Raising a journal one beside it would put the same
// money in the register twice.
if (!this.voucherService.shouldRaiseVoucher()) {
return null;
}
try {
final List<JournalEntry> entries = this.glJournalEntryRepository
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
if (CollectionUtils.isEmpty(entries)) {
return null;
}
final List<VoucherLine> lines = new ArrayList<>();
for (final JournalEntry entry : entries) {
final GLAccount account = entry.getGlAccount();
final Long accountId = account == null ? null : account.getId();
final String name = account == null ? null : account.getName();
final String code = account == null ? null : account.getGlCode();
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
}
// Debits first, so the voucher prints the way a slip is read.
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
final AppUser currentUser = this.context.authenticatedUser();
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
return null;
}
}
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
/**
@@ -328,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
}
}
@@ -712,15 +779,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
validateBusinessRulesForJournalEntries(journalEntryCommand);
validateNoDenominationManagedCashAccounts(journalEntryCommand);
/**
* 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,
officeId);
for (String transactionId : transactionIdsToBeReversed) {
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
.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");
}
@@ -802,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
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
@@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -67,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
BusinessEventNotifierService businessEventNotifierService) {
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
businessEventNotifierService);
businessEventNotifierService, jdbcTemplate);
}
@Bean
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
ExternalAssetOwnerRepository externalAssetOwnerRepository,
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
LoanTransactionRepository loanTransactionRepository) {
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
voucherService);
}
}
@@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
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
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
@@ -0,0 +1,264 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* How much profit is available to fund a share dividend, for a given period.
*
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
* for another share product over the same period, since they all draw on the same balance.
*
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
* screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a
* second time would hold back money that has already left the account.
*
* Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
*/
@Path("/v1/accounting/distributable-profit")
@Component
@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.")
@RequiredArgsConstructor
public class DistributableProfitApiResource {
/**
* The year's closed-out profit, which is what a dividend may be declared against.
*
* Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
* offer profit already distributed once for distribution again.
*/
private final DividendGlAccountResolver dividendGlAccounts;
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("excludeProductId") final Long excludeProductId) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
if (to.before(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
// A dividend is normally declared for a whole, closed financial year, and then the audited
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
// only be an interim declaration, which necessarily runs against the still-open P&L.
final String financialYear = financialYearOf(from.toLocalDate());
final LocalDate[] fyRange = financialYearRange(financialYear);
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
final boolean yearClosed = closedProfit != null;
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
// the profit-distribution screen before any dividend is declared, so the profit this reads
// has already had them taken out — subtracting them again would hold back money that is no
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
// than a negative figure a caller could read as a credit.
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO;
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
BigDecimal alreadyDeclared = BigDecimal.ZERO;
for (final Map<String, Object> declaration : existingDeclarations) {
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
}
// An over-declaration that already happened (or appropriation rates raised after the fact)
// must not present as negative headroom, which a caller could read as a credit.
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("fromDate", fromDate);
response.put("toDate", toDate);
response.put("financialYear", financialYear);
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
response.put("yearClosed", yearClosed);
// Names the figure so the screen can say where it came from — the closed books, or a P&L
// that is still moving — rather than showing a bare number the officer has to interpret.
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
response.put("netProfit", netProfit);
response.put("distributableProfit", distributableProfit);
response.put("alreadyDeclared", alreadyDeclared);
response.put("availableForDeclaration", availableForDeclaration);
response.put("maximumRatePercentage", maximumRatePercentage);
response.put("existingDeclarations", existingDeclarations);
return gson.toJson(response);
}
/**
* The year's profit as the books actually record it, once the year has been closed.
*
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
*
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
* figure that is still moving.
*/
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
final Integer closedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
if (closedCount == null || closedCount == 0) {
return null;
}
final LocalDate[] range = financialYearRange(financialYear);
final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
private LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (final RuntimeException e) {
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
}
}
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
private String financialYearOf(final LocalDate date) {
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/**
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
*/
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
return totalIncome.subtract(totalExpense);
}
/**
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
* society-wide, so a second product's declaration competes for the same balance as the first.
*
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
* declaration is separately blocked outright.
*/
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
final StringBuilder sql = new StringBuilder("""
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
d.dividend_period_start_date, d.dividend_period_end_date, d.status
FROM m_share_product_dividend_pay_out d
JOIN m_share_product p ON p.id = d.product_id
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
""");
final List<Object> params = new ArrayList<>(List.of(to, from));
if (excludeProductId != null) {
sql.append(" AND d.product_id <> ? ");
params.add(excludeProductId);
}
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
final List<Map<String, Object>> declarations = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> declaration = new LinkedHashMap<>();
declaration.put("declarationId", row.get("declaration_id"));
declaration.put("productId", row.get("product_id"));
declaration.put("productName", row.get("product_name"));
declaration.put("amount", row.get("amount"));
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
declaration.put("status", row.get("status"));
declarations.add(declaration);
}
return declarations;
}
/**
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
* which is 0 — not a failure. Only an enabled row with a value applies.
*/
private BigDecimal configuredPercentage(final String configurationName) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return BigDecimal.ZERO;
}
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,180 @@
package org.apache.fineract.accounting.yearend.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
*
* <p>
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
*/
@Path("/v1/accounting/incomeprofittransfer")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class IncomeProfitTransferApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final IncomeProfitTransferService service;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// GET — how much is waiting to be posted, and what has been posted before
// ══════════════════════════════════════════════════════════════════════
@GET
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
@QueryParam("financialYear") final String financialYearParam) {
this.context.authenticatedUser();
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: incomeGlCodeParam.trim();
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = service.loadDistributableProfitGl();
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
: financialYearParam.trim();
final LocalDate[] range = service.financialYearRange(financialYear);
final Long incomeGlId = toLong(incomeGl.get("id"));
final BigDecimal pending = service.balanceOf(incomeGlId, range);
final int entryCount = service.entryCountOf(incomeGlId, range);
final List<Map<String, Object>> history = jdbcTemplate
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("pendingAmount", pending);
out.put("entryCount", entryCount);
out.put("alreadyPosted", alreadyPosted);
out.put("postedHistory", history);
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — close the income GL into Bank Profit
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response post(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: getString(json, "incomeGlCode").trim();
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
// error, so the user is told why nothing happened.
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /close-year — close the WHOLE P&L into Bank Profit
// ══════════════════════════════════════════════════════════════════════
/**
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
*
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
*/
@POST
@Path("/close-year")
@Transactional
public Response closeYear(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
// told why nothing happened rather than getting a silent success.
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
}
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class PostShareEntryFeeToProfitConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private IncomeProfitTransferService incomeProfitTransferService;
@Bean
protected Step postShareEntryFeeToProfitStep() {
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
}
@Bean
public Job postShareEntryFeeToProfitJob() {
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
}
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
* Profit equity GL, leaving the income account at nil for the new year.
*
* <p>
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
* rather than failing the job. Only a genuine posting failure is raised.
*/
@Slf4j
@RequiredArgsConstructor
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
private static final long SYSTEM_USER_ID = 1L;
private final IncomeProfitTransferService incomeProfitTransferService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final String financialYear = incomeProfitTransferService.currentFinancialYear();
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
// is now what the share dividend declaration reads as "the year's profit", and a partial close
// would leave most of the profit still sitting in the income GLs — understating what may be
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
"Automatic year-end closing", false);
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,396 @@
package org.apache.fineract.accounting.yearend.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.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.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Closes an income GL into the Bank Profit equity GL at financial year end.
*
* <pre>
* Dr Share Entry Fee Income (10.4) = balance for the year
* Cr Distributable Profit (the GL mapped to that financial activity)
* </pre>
*
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
* retained profit.
*
* <p>
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
* logged-in user.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IncomeProfitTransferService {
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int DEBIT = 2;
private static final int CREDIT = 1;
/** Income GL closed to profit by the year-end job — the share entry fee. */
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
private final JdbcTemplate jdbcTemplate;
/**
* 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
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
* errors.
*
* @param strict
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
* returns quietly.
*/
@Transactional
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
final String note, final boolean strict) {
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = loadDistributableProfitGl();
final Long incomeGlId = toLong(incomeGl.get("id"));
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, incomeGlId, officeId);
if (existing != null && existing > 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "Already closed for " + financialYear);
return out;
}
final BigDecimal balance = balanceOf(incomeGlId, range);
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "No income to close for " + financialYear);
return out;
}
final int entryCount = entryCountOf(incomeGlId, range);
final String currencyCode = resolveCurrency(incomeGlId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YEPROFIT-" + System.nanoTime();
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + incomeGl.get("name"));
jdbcTemplate.update(
"INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
financialYear);
out.put("posted", true);
out.put("amountPostedToProfit", balance);
out.put("entryCount", entryCount);
out.put("postedOnDate", postingDate.toString());
out.put("glTransactionId", txnId);
out.put("currency", currencyCode);
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
return out;
}
/**
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
* net profit.
*
* <pre>
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
* </pre>
*
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
* profit split across two places and understates it.
*
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
* Accounts with a nil balance are skipped rather than posted as zero entries.
*/
@Transactional
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
final boolean strict) {
final Map<String, Object> profitGl = loadDistributableProfitGl();
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
// historical balance but must not attract new postings.
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
final List<Map<String, Object>> closed = new ArrayList<>();
final List<Map<String, Object>> skipped = new ArrayList<>();
BigDecimal totalIncome = BigDecimal.ZERO;
BigDecimal totalExpense = BigDecimal.ZERO;
for (final Map<String, Object> gl : plAccounts) {
final Long glId = toLong(gl.get("id"));
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, glId, officeId);
if (existing != null && existing > 0) {
skipped.add(skipRow(gl, "Already closed for " + financialYear));
continue;
}
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
// expense GL the natural balance is a debit, so the same figure comes back negative and
// is flipped here to read as a positive expense.
final BigDecimal rawBalance = balanceOf(glId, range);
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
continue;
}
final int entryCount = entryCountOf(glId, range);
final String currencyCode = resolveCurrency(glId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
if (isIncome) {
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + gl.get("name"));
totalIncome = totalIncome.add(balance);
} else {
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
"Expense for " + financialYear + " from " + gl.get("name"));
totalExpense = totalExpense.add(balance);
}
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
java.sql.Date.valueOf(postingDate), userId, userName, note);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("type", isIncome ? "INCOME" : "EXPENSE");
row.put("amount", balance);
row.put("entryCount", entryCount);
row.put("glTransactionId", txnId);
closed.add(row);
}
if (closed.isEmpty() && strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
"There is nothing left to close to profit for " + financialYear + ".");
}
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
netProfit, profitGl.get("name"));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("totalIncomeClosed", totalIncome);
out.put("totalExpenseClosed", totalExpense);
out.put("netProfitPosted", netProfit);
out.put("accountsClosed", closed);
out.put("accountsSkipped", skipped);
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
+ netProfit + ".");
return out;
}
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("reason", reason);
return row;
}
/**
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
* year has been closed.
*
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
* whether that is a blocking condition.
*/
public BigDecimal profitForFinancialYear(final String financialYear) {
final LocalDate[] range = financialYearRange(financialYear);
final Map<String, Object> profitGl = loadDistributableProfitGl();
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** Whether the year's P&L has been closed into Bank Profit at all. */
public boolean isYearClosed(final String financialYear) {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
Integer.class, financialYear);
return count != null && count > 0;
}
/** Net credit balance sitting in the income GL for the year. */
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** How many fee postings made up that balance. */
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return count == null ? 0 : count;
}
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
public String currentFinancialYear() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/** 1 April to 31 March for a "YYYY-YY" label. */
public LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
"financialYear must look like 2026-27. Received: " + financialYear);
}
}
public Map<String, Object> loadGlByCode(final String glCode) {
try {
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
}
}
/** 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() {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
}
private String resolveCurrency(final Long glAccountId) {
try {
final String code = jdbcTemplate.queryForObject(
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
glAccountId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -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;
}
}
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return false;
}
@Override
public boolean isMakerCheckerEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
}
@Override
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -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.filters.CallerIpTrackingFilter;
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.IdempotencyStoreHelper;
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
@@ -163,6 +164,24 @@ public class SecurityConfig {
// packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
// it just fails inside the handler instead of at this filter.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
// Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above
// for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary
// session when no key is sent, same as ClientAllAccountsApiResource does; permitAll
// here only opens Spring Security's outer gate for the key path, it does not skip
// the handler's own check). The PUT has no session fallback inside the handler at
// all, even for a logged-in super user — see TenantMenuAccessApiResource#save.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll()
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
// no Fineract auth required.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
@@ -408,7 +427,10 @@ public class SecurityConfig {
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.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) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
@@ -445,6 +467,10 @@ public class SecurityConfig {
return new RequestResponseFilter();
}
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
return new DisabledFeatureApiFilter();
}
public LoanCOBApiFilter loanCOBApiFilter() {
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
.retrieveAllJobs(fineractProperties.getNodeId());
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 {
jobRegisterService.scheduleJob(jobDetails);
} catch (JobIsNotFoundOrNotEnabledException e) {
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
*/
@Path("/v1/apiclients")
@Component
@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.")
@RequiredArgsConstructor
public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
requireSuperUser();
final List<Map<String, Object>> clients = credentialService.listClients();
return gson.toJson(clients);
}
@DELETE
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String deactivate(@PathParam("id") final Long id) {
requireSuperUser();
credentialService.setActive(id, false);
return gson.toJson(Map.of("resourceId", id, "isActive", false));
}
@POST
@Path("/{id}/reactivate")
@Produces(MediaType.APPLICATION_JSON)
public String reactivate(@PathParam("id") final Long id) {
requireSuperUser();
credentialService.setActive(id, true);
return gson.toJson(Map.of("resourceId", id, "isActive", true));
}
private AppUser requireSuperUser() {
final AppUser user = context.authenticatedUser();
if (!user.hasAnyPermission("ALL_FUNCTIONS")) {
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON)
.build());
}
return user;
}
}
@@ -0,0 +1,243 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.security.apiclient;
import jakarta.annotation.PostConstruct;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user
* login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this
* fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at
* the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate
* stays open for the specific path, and this class is the thing that actually decides "yes" or "no".
*
* {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via
* the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean
* {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary
* tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not
* an oversight: a key is a caller's identity, independent of which bank's data a given request is asking
* for, so one minted key must keep working against every tenant that exists now AND every one
* provisioned later. A copy of this table inside each bank's own tenant database would need a manual
* re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing
* it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes
* the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to
* the right bank's database) -- this class only ever touches the one central table.
*
* Security properties, deliberately:
* <ul>
* <li>Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked
* database backup can never be turned back into a usable credential -- the same reason Fineract itself
* never stores a user's plaintext password.</li>
* <li>The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is
* not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and
* deactivate the old.</li>
* <li>{@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key
* doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker
* cannot use the response to tell a wrong key apart from a valid-but-blocked one.</li>
* <li>The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a
* small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave
* {@code allowedIps} null for that key rather than trying to enumerate every address.</li>
* </ul>
*/
@Component
@RequiredArgsConstructor
public class ApiClientCredentialService {
private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key.";
// Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a
// session token, appropriate for a credential that never expires on its own.
private static final int KEY_ENTROPY_BYTES = 32;
@Qualifier("hikariTenantDataSource")
private final DataSource tenantStoreDataSource;
private final SecureRandom secureRandom = new SecureRandom();
// Built once from the qualified DataSource in @PostConstruct rather than injected directly as a
// JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields,
// and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is.
private JdbcTemplate tenantStoreJdbc;
@PostConstruct
void init() {
this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource);
}
/**
* Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's
* display name (for the caller to log/attribute the request to) and records this call -- including
* which tenant it was made against -- as the key's most recent use. On any failure, throws a clean
* 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP.
*/
public String authenticate(final String rawApiKey, final String callerIp) {
if (StringUtils.isBlank(rawApiKey)) {
throw unauthorized();
}
final String hash = sha256Hex(rawApiKey);
final List<Map<String, Object>> rows = tenantStoreJdbc.queryForList("""
SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ?
""", hash);
if (rows.isEmpty()) {
throw unauthorized();
}
final Map<String, Object> row = rows.get(0);
if (!Boolean.TRUE.equals(row.get("is_active"))) {
throw unauthorized();
}
final String allowedIps = (String) row.get("allowed_ips");
if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) {
throw unauthorized();
}
final Long id = ((Number) row.get("id")).longValue();
tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?",
java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id);
return (String) row.get("client_name");
}
private boolean isIpAllowed(final String allowedIps, final String callerIp) {
if (StringUtils.isBlank(callerIp)) {
return false;
}
return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp));
}
/**
* Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value
* is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key
* authenticates against every tenant, regardless of which one the admin minting it happened to be
* logged into.
*/
public Map<String, Object> createClient(final String clientName, final String allowedIps, final Long createdByUserId) {
if (StringUtils.isBlank(clientName)) {
throw badRequest("clientName is required.");
}
final String rawKey = generateRawKey();
final String hash = sha256Hex(rawKey);
final String prefix = rawKey.substring(0, Math.min(12, rawKey.length()));
final Long id = tenantStoreJdbc.queryForObject("""
INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant)
VALUES (?,?,?,?,?,?)
RETURNING id
""", Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId,
currentTenantId());
final Map<String, Object> result = new LinkedHashMap<>();
result.put("id", id);
result.put("clientName", clientName);
result.put("apiKey", rawKey);
result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again.");
return result;
}
public List<Map<String, Object>> listClients() {
return tenantStoreJdbc.queryForList("""
SELECT id, client_name, api_key_prefix, is_active, allowed_ips,
created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant
FROM m_api_client ORDER BY created_date DESC
""");
}
public void setActive(final Long id, final boolean active) {
final int updated = tenantStoreJdbc.update(
"UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active,
active ? null : java.sql.Timestamp.from(Instant.now()), id);
if (updated == 0) {
throw notFound("No API client with id " + id);
}
}
private String currentTenantId() {
try {
return ThreadLocalContextUtil.getTenant().getTenantIdentifier();
} catch (final RuntimeException e) {
// Defensive only: every request through SecurityConfig's matcher chain has a tenant
// resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this
// should be unreachable in practice.
return null;
}
}
private String generateRawKey() {
final byte[] bytes = new byte[KEY_ENTROPY_BYTES];
secureRandom.nextBytes(bytes);
// URL-safe, no padding: safe to put directly in a header value with no further encoding.
return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String sha256Hex(final String raw) {
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
for (final byte b : hashBytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (final NoSuchAlgorithmException e) {
// SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports --
// this can only mean a broken JVM install, not a recoverable runtime condition.
throw new IllegalStateException("SHA-256 is unavailable in this JVM", e);
}
}
private WebApplicationException unauthorized() {
// Naming the tenant the caller sent is safe here -- it is the header value they themselves
// put on the request, not something this check reveals to them -- and it turns "why did my
// key fail" from a guess into something checkable: was this call even reaching the bank the
// caller thought it was?
final String tenantId = currentTenantId();
final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE;
return jsonError(401, message);
}
private WebApplicationException badRequest(final String message) {
return jsonError(400, message);
}
private WebApplicationException notFound(final String message) {
return jsonError(404, message);
}
private WebApplicationException jsonError(final int status, final String message) {
final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}";
return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,214 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Which top-nav menu sections a tenant is allowed to ever show, regardless of what any role inside
* that tenant grants itself.
*
* {@code HasPermissionDirective} on the frontend already hides a menu item a user's own role lacks --
* but a role carrying {@code ALL_FUNCTIONS} (a super user, or a role simply misconfigured to hold it,
* as the "Clerk" role was found to in two tenants this session) bypasses that check unconditionally.
* A bank's own admin screen for roles and permissions therefore cannot be trusted as the only gate on
* "does this bank ever get to see the HRM/NPA/Admin section" -- whoever ends up with ALL_FUNCTIONS in
* that tenant, today or after some future role edit, would still see it. This table is the ceiling
* that check now also has to clear.
*
* Reading it accepts either credential (see {@link #read} below) -- a bank's own user needs their own
* tenant's list to render their nav correctly, the same way they already read their own permissions,
* and the platform operator needs to read ANY tenant's current list before toggling it. Writing it
* accepts only the platform's own {@code X-Api-Key} + {@link ApiClientCredentialService} credential --
* the same mechanism {@code ClientAllAccountsApiResource} uses for third-party callers, but with no
* session fallback at all here. That is deliberate -- a bank's own super user must never be able to
* lift its own ceiling by granting themselves a permission from inside their own tenant, which is
* exactly what a normal {@code ALL_FUNCTIONS} gate would allow. The key is minted once via
* {@code POST /v1/apiclients} by the platform operator and never handed to a bank.
*/
@Path("/v1/tenant-menu-access")
@Component
@Tag(name = "Tenant Menu Access",
description = "Per-tenant cap on which top-nav menu sections can ever be shown, independent of any role's own permissions. Read by any logged-in user of the tenant; written only via a platform-operator API key.")
@RequiredArgsConstructor
public class TenantMenuAccessApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ApiClientCredentialService apiClientCredentialService;
private final TenantDetailsService tenantDetailsService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
// The whole, fixed set of top-nav menu codes this screen knows about -- see 0541 for how this
// list was seeded. A write is only ever a PATCH over this table's existing rows (see save()); a
// code outside this set cannot be created by the API, so a typo in a request body is silently a
// no-op rather than junk data the frontend would then have to guard against.
private static final Set<String> KNOWN_MENU_CODES = Set.of("MENU_INSTITUTION", "SIDEBAR_CFCFRMS", "MENU_TASKS",
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
/**
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
* Dual-mode read, same posture as {@code ClientAllAccountsApiResource}: an ordinary logged-in
* user reads their own tenant's list exactly like they already read their own permission list
* (no elevated permission required), or the platform operator reads ANY tenant's list with the
* API key, to know the current state before toggling it on their own control screen. Only the
* write side ({@link #save}) has no session fallback -- reading configuration is not sensitive,
* changing it is.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
public String read(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
if (StringUtils.isNotBlank(apiKey)) {
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
} else {
context.authenticatedUser();
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access");
final JsonArray out = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject o = new JsonObject();
o.addProperty("menuCode", (String) row.get("menu_code"));
o.addProperty("enabled", Boolean.TRUE.equals(row.get("enabled")));
out.add(o);
}
return gson.toJson(out);
}
/**
* Every tenant configured on this platform, for the operator's own tenant picker -- always the
* master list from the tenant-store database, never the one tenant the caller's own
* {@code Fineract-Platform-TenantId} header happened to resolve to. That header still has to name
* a real tenant for the request to reach this handler at all (see
* {@code TenantAwareBasicAuthenticationFilter}); which one is otherwise irrelevant here.
*/
@GET
@Path("/tenants")
@Produces(MediaType.APPLICATION_JSON)
public String listTenants(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
requireApiKey(apiKey, servletRequest);
final JsonArray out = new JsonArray();
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
final JsonObject o = new JsonObject();
o.addProperty("identifier", tenant.getTenantIdentifier());
o.addProperty("name", tenant.getName());
out.add(o);
}
return gson.toJson(out);
}
/**
* Sets which menu codes the tenant named in {@code Fineract-Platform-TenantId} may show, from the
* operator's own control screen. API key only -- see the class comment for why a normal session,
* even a super user's, is never accepted here.
*
* Body: {@code [{"menuCode": "MENU_HRM", "enabled": false}, ...]}. Only codes already in
* {@link #KNOWN_MENU_CODES} are written; anything else in the body is ignored rather than
* rejected, so the operator's screen can always send its full known set without first checking
* this deployment's exact backend version.
*/
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String save(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest,
final String body) {
requireApiKey(apiKey, servletRequest);
final JsonArray entries = gson.fromJson(body, JsonArray.class);
if (entries == null) {
throw apiError(400, "Request body must be a JSON array of {menuCode, enabled}.");
}
final Set<String> touched = new LinkedHashSet<>();
for (final var el : entries) {
final JsonObject entry = el.getAsJsonObject();
if (!entry.has("menuCode") || entry.get("menuCode").isJsonNull()) {
continue;
}
final String menuCode = entry.get("menuCode").getAsString();
if (!KNOWN_MENU_CODES.contains(menuCode)) {
continue;
}
final boolean enabled = entry.has("enabled") && !entry.get("enabled").isJsonNull() && entry.get("enabled").getAsBoolean();
jdbcTemplate.update("UPDATE m_tenant_menu_access SET enabled = ? WHERE menu_code = ?", enabled, menuCode);
touched.add(menuCode);
}
final JsonObject res = new JsonObject();
res.addProperty("tenant", ThreadLocalContextUtil.getTenant().getTenantIdentifier());
res.addProperty("updated", touched.size());
return gson.toJson(res);
}
/** No session fallback, unlike every other API-key-capable endpoint in this codebase -- see the class comment. */
private void requireApiKey(final String apiKey, final HttpServletRequest servletRequest) {
if (StringUtils.isBlank(apiKey)) {
throw apiError(403, "This endpoint is only reachable with a platform operator API key.");
}
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
}
private String resolveClientIp(final HttpServletRequest request) {
for (final String header : IP_HEADER_CANDIDATES) {
final String value = request.getHeader(header);
if (StringUtils.isNotBlank(value)) {
return value.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
@@ -200,6 +201,20 @@ public class OfficesApiResource {
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
@Path("/external-id/{externalId}")
@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 deleteOffice(Long officeId, JsonCommand command);
CommandProcessingResult officeTransaction(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.Caching;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final OfficeTransactionRepository officeTransactionRepository;
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
@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
@Override
public CommandProcessingResult officeTransaction(final JsonCommand command) {
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
applicationCurrencyRepository);
applicationCurrencyRepository, jdbcTemplate);
}
}
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
accountsSummary.append("sa.deposit_type_enum as depositType ");
accountsSummary.append("sa.deposit_type_enum as depositType, ");
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
// Null for a savings/current account and for an FD opened before this column existed;
// callers fall back to accountNo, so an FD that has never been renewed still reads as
// its own FD number.
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null);
}
}
@@ -0,0 +1,299 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.accountregister.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Account register for one date: which accounts were opened and which were closed that day, with
* each account's balance as it stood on that date alongside what it holds now.
*
* The dated balance is read from the running balance stamped on the last transaction up to that
* date, rather than recomputed from the account's current figure — a current balance tells you
* nothing about what the account held three weeks ago.
*/
@Path("/v1/accountregister")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AccountRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
this.context.authenticatedUser();
final LocalDate asOn;
if (dateParam != null && !dateParam.isBlank()) {
try {
asOn = LocalDate.parse(dateParam);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
"Date must be in yyyy-MM-dd format.");
}
} else {
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
final List<Map<String, Object>> rows = new ArrayList<>();
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
rows.addAll(depositAccounts(asOn, want));
}
if (matches(want, "SHARE")) {
rows.addAll(shareAccounts(asOn));
}
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
// so the totals can never disagree with the list they summarise.
int opened = 0;
int closed = 0;
BigDecimal balanceOnDate = BigDecimal.ZERO;
BigDecimal currentBalance = BigDecimal.ZERO;
for (final Map<String, Object> r : rows) {
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
opened++;
}
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
closed++;
}
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("date", asOn.toString());
out.put("section", want);
out.put("accounts", rows);
out.put("count", rows.size());
out.put("openedCount", opened);
out.put("closedCount", closed);
out.put("totalBalanceOnDate", balanceOnDate);
out.put("totalCurrentBalance", currentBalance);
return gson.toJson(out);
}
private static boolean matches(final String want, final String section) {
return "ALL".equals(want) || want.equals(section);
}
private static BigDecimal dec(final Object o) {
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
}
/**
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
*
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
* them would make a register of a past day describe a present one.
*/
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
final Date date = Date.valueOf(asOn);
final StringBuilder typeFilter = new StringBuilder();
if (!"ALL".equals(want)) {
final int type = switch (want) {
case "SAVINGS" -> 100;
case "FD" -> 200;
case "RD" -> 300;
default -> 0;
};
if (type > 0) {
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
}
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
sa.account_balance_derived,
c.display_name AS client_name,
p.name AS product_name,
(SELECT t.running_balance_derived
FROM m_savings_account_transaction t
WHERE t.savings_account_id = sa.id
AND COALESCE(t.is_reversed, false) = false
AND t.transaction_date <= ?
ORDER BY t.transaction_date DESC, t.id DESC
LIMIT 1) AS balance_on_date
FROM m_savings_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_savings_product p ON p.id = sa.product_id
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
""" + typeFilter + """
ORDER BY sa.deposit_type_enum, sa.account_no
""", date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
final int depositType = num(r.get("deposit_type_enum"));
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", savingsStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activatedon_date")));
m.put("closedOn", str(r.get("closedon_date")));
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
// No transaction up to that date means the account had nothing in it yet, not that the
// balance is unknown.
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
m.put("currentBalance", r.get("account_balance_derived") == null
? BigDecimal.ZERO : r.get("account_balance_derived"));
out.add(m);
}
return out;
}
/**
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
* are individually dated, so the holding on a given date is the shares purchased up to that
* date less those redeemed — which is a real dated figure, not the current one relabelled.
*/
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
final Date date = Date.valueOf(asOn);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
sa.total_approved_shares,
c.display_name AS client_name,
p.name AS product_name,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)) AS share_value,
-- Purchases less redemptions dated on or before the register's date. Charge
-- rows (type 700) are excluded: an entry fee is not share capital. Only
-- approved purchases count (status 300) — a rejected application leaves its
-- row behind still flagged active, and counting it credited a member with
-- shares they were never issued.
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS value_on_date,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
WHEN t.type_enum = 600 THEN -t.total_shares
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS shares_on_date
FROM m_share_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_share_product p ON p.id = sa.product_id
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
ORDER BY sa.account_no
""", date, date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", "SHARE");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", shareStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activated_date")));
m.put("closedOn", str(r.get("closed_date")));
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
// Shares as at the date, not the approved figure — an account can hold fewer than were
// approved once some have been redeemed.
m.put("sharesHeld", r.get("shares_on_date"));
out.add(m);
}
return out;
}
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
return dbDate != null && asOn.toString().equals(dbDate.toString());
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
private static int num(final Object o) {
return o instanceof Number n ? n.intValue() : 0;
}
private static String savingsStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
case 700 -> "TRANSFER IN PROGRESS";
case 800 -> "TRANSFER ON HOLD";
case 900 -> "PREMATURELY CLOSED";
default -> "UNKNOWN";
};
}
private static String shareStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
default -> "UNKNOWN";
};
}
}
@@ -113,6 +113,37 @@ public interface ShareAccountApiConstants {
* outstanding balance. */
String linkedloanid_paramname = "linkedLoanId";
/**
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
*
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
* differ only in how it got there: CASH means the cashier already took it at the counter
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
*/
String fundingsource_paramname = "fundingSource";
String FUNDING_SOURCE_CASH = "CASH";
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
/**
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
*
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
* see which receipt the money they are releasing came in on.
*/
String advancecollectionid_paramname = "advanceCollectionId";
/**
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
*
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
* rest, and their combined total is what has to cover the shares.
*/
String additionalcollectionids_paramname = "additionalCollectionIds";
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
@@ -25,6 +25,7 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.YearMonth;
import java.util.List;
@@ -32,6 +33,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
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.AmlCtrReportReadPlatformService;
import org.springframework.stereotype.Component;
@@ -47,6 +49,7 @@ public class AmlCtrReportApiResource {
private final AmlCtrReportReadPlatformService readPlatformService;
private final AmlCtrReportGenerationService generationService;
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<AmlCtrReportLineData> lineJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -58,6 +61,17 @@ public class AmlCtrReportApiResource {
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
@Path("generate")
@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;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
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.
*/
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,
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.transactionId = transactionId;
this.client = client;
@@ -56,6 +92,26 @@ public final class AmlTransactionContext {
this.occurredOn = occurredOn;
this.cash = cash;
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() {
@@ -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.SavingsWithdrawalBusinessEvent;
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.domain.AmlTransactionType;
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 List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
// Same side of the ledger as the transaction being observed: a deposit is not part of a
// 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,
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 {
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
@@ -134,12 +145,18 @@ public class AmlDomainServiceImpl implements AmlDomainService {
}
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
final List<LoanTransaction> repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList();
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,
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 {
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
@@ -20,8 +20,17 @@ package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
public interface AmlCtrReportReadPlatformService {
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;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
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.AmlCtrReportRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -33,7 +36,40 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
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 JdbcTemplate jdbcTemplate;
@Override
public List<AmlCtrReportData> retrieveAll() {
@@ -41,6 +77,29 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
.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) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
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.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
@@ -31,11 +32,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
* transaction that has already succeeded. Joining the caller's transaction would let a failed
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
* contained to AML.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
@@ -45,6 +55,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
private final AmlSubjectScopeResolver subjectScopeResolver;
@Override
public List<AmlAlert> evaluate(AmlTransactionContext context) {
@@ -72,6 +83,9 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (!appliesToThisTransaction(rule, context)) {
return false;
}
final BigDecimal threshold = resolveThreshold(rule, context);
if (threshold == null) {
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
@@ -82,14 +96,125 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
case THRESHOLD:
return context.getAmount().compareTo(threshold) >= 0;
case STRUCTURING:
return isStructuringHit(rule, context, threshold);
case VELOCITY:
return context.getWindowAggregateAmount() != null
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
return isVelocityHit(rule, context, threshold);
default:
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
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
@@ -0,0 +1,136 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDate;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.data.AmlSubjectScope;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
/**
* Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is
* written about.
*
* <p>
* Everything here is read from what the customer record already holds - the client type chosen at
* onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount,
* because the amount is what the rule's threshold is already for.
*/
@Service
@RequiredArgsConstructor
public class AmlSubjectScopeResolver {
/**
* Client types that make an account a charitable/not-for-profit one for the purposes of the
* cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are
* per-tenant and these are seeded per tenant.
*/
private static final Set<String> CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST");
/**
* Businesses RBI names as carrying higher money-laundering risk, where a large transaction is
* worth a second look that the same amount elsewhere would not be.
*/
private static final Set<String> HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT",
"DEALER IN PRECIOUS METALS", "CASINO");
/** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */
private static final int NEW_ACCOUNT_DAYS = 180;
private static final int HIGH_RISK_KYC_CATEGORY = 3;
private final ClientKycRepository clientKycRepository;
/**
* Whether a rule carrying this scope should look at this transaction at all.
*
* <p>
* A rule with no scope is about the transaction rather than the customer and applies to
* everyone, which is how these rules behaved before the column existed.
*/
public boolean matches(final String configuredScope, final AmlTransactionContext context) {
final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope);
if (scope == null) {
return true;
}
final Client client = context.getClient();
if (client == null) {
return false;
}
return switch (scope) {
case PEP -> isPoliticallyExposed(client);
case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES);
case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES);
case NEW_ACCOUNT -> isNewAccount(context);
};
}
/**
* There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that
* fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is
* what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not
* treated as a PEP - a rule that cannot identify its subject must raise nothing rather than
* report everyone.
*/
private boolean isPoliticallyExposed(final Client client) {
if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP")
|| codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) {
return true;
}
return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue)
.filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent();
}
private boolean isClientTypeIn(final Client client, final Set<String> wanted) {
final String type = client.clientType() == null ? null : client.clientType().getLabel();
final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel();
return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification);
}
/**
* Measured from the customer's activation date against the business date, not the wall clock,
* so a back-dated test and a back-dated correction both read the age the books read.
*/
private boolean isNewAccount(final AmlTransactionContext context) {
final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn()
: context.getClient().getActivationDate();
if (openedOn == null) {
return false;
}
return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate());
}
private boolean containsIgnoreCase(final Set<String> wanted, final String value) {
if (value == null) {
return false;
}
final String normalised = value.trim().toUpperCase();
return wanted.contains(normalised);
}
private boolean codeValueMatches(final String value, final String token) {
return value != null && value.trim().toUpperCase().contains(token);
}
}
@@ -64,9 +64,13 @@ public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationServ
return;
}
// Moves the re-KYC due date onto the new category's cycle as well, so the customer is
// called back in on the schedule the escalated rating requires.
kyc.escalateRiskCategory(nextCategory);
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
context.authenticatedUser(), LocalDateTime.now()));
log.info("Client {} auto-escalated from risk category {} to {} on the {}rd cumulative STR case; re-KYC now due {}",
client.getId(), previousCategory, nextCategory, cumulativeCases, kyc.getExpiryDate());
}
private Integer nextCategory(Integer currentCategory) {
@@ -53,15 +53,28 @@ public class CashExchangeApiResource {
@POST
@Transactional
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();
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()
: 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()
? 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 customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
? json.get("customerIdProof").getAsString() : null;
@@ -153,6 +166,10 @@ public class CashExchangeApiResource {
@POST
@Path("{id}/reverse")
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)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
@@ -54,7 +54,12 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.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.VoucherService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -67,9 +72,28 @@ import com.google.common.base.Splitter;
@Component
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
@RequiredArgsConstructor
@lombok.extern.slf4j.Slf4j
public class CashierDepositRequestApiResource {
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
// activity JsonArray, and a field of the same name reads as that variable at a glance.
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
// 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 KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -124,6 +148,7 @@ public class CashierDepositRequestApiResource {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero");
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue);
@@ -161,7 +186,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
currentUser.getId(), currentUser.getUsername());
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -394,6 +419,20 @@ public class CashierDepositRequestApiResource {
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
// hit an edge case in the new hold-placement logic would fail entirely, where before
// this feature existed it always succeeded. Catching it keeps the Clearing Hold strictly
// best-effort: the deposit approval this whole endpoint exists for must never depend on
// it, only benefit from it when it works.
try {
placeClearingHoldIfInstrumentDeposit(savingsAccountId, ptId, amount, txnDateStr);
} catch (final RuntimeException e) {
log.warn("Could not place a Clearing Hold for cashier deposit request {} (savings account {}): {}", id,
savingsAccountId, e.getMessage(), e);
}
}
}
// Link voucher to the created savings txn and stamp voucher_no on it.
@@ -406,6 +445,10 @@ public class CashierDepositRequestApiResource {
}
}
// The sides of the slip, recorded now rather than at submission: which accounts the money
// actually moved between is only settled once the request is approved and posted.
addDepositVoucherLines(record, savingsAccountId, amount);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
);
@@ -426,13 +469,15 @@ public class CashierDepositRequestApiResource {
}
// 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.
// parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry).
// Now update the temporary txn_id (offset request id -- see PENDING_DEPOSIT_TXN_ID_OFFSET) to
// the real Fineract transaction id. parseTxnIdToLong handles both decimal (savings/current)
// and hex (GL journal entry).
if (txnId != null) {
final Long realTxnId = parseTxnIdToLong(txnId);
if (realTxnId != null) {
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);
}
}
@@ -484,9 +529,10 @@ public class CashierDepositRequestApiResource {
throw apiError(409, "This deposit request was already actioned by another manager.");
}
// Reverse denomination — cashier physically returned cash to customer on rejection.
// The temporary denomination records (saved at submit time with txn_id = request id) are removed.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id);
// Reverse denomination — cashier physically returned cash to customer on rejection. The
// temporary denomination records (saved at submit time with txn_id = offset request id -- see
// 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"));
}
@@ -540,6 +586,62 @@ public class CashierDepositRequestApiResource {
// ── helpers ──────────────────────────────────────────────────────────────────
/**
* Records the two sides of an approved deposit on its voucher.
*
* A deposit is money arriving at the counter, so cash is debited and whatever receives it is
* credited — a member's savings account, or a GL account when the deposit was raised against
* one. The credited side is read from the request, because that is what the cashier actually
* filled in and what the member signed for.
*
* A failure here does not fail the approval. The deposit is already posted and the member's
* balance already raised; refusing that over its slip would take a correct transaction back for
* a paperwork reason. The problem is logged, and the voucher keeps its number without sides.
*/
private void addDepositVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type"));
final String accountNo = (String) record.get("account_no");
// Cash reaching the counter. For a GL deposit the contra is the cash/vault account the
// request named; for a member deposit it is the cashier's own till, which the deposit
// register does not carry a GL id for — the line still names it, without an id.
final Object contraObj = record.get("contra_gl_id");
final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null;
final Map<String, Object> contra = glAccountForVoucher(contraGlId);
final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"),
(String) contra.get("gl_code"), amount);
final VoucherLine credit;
if (isGl) {
final Object glObj = record.get("gl_account_id");
final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null;
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
} else {
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
}
voucherService.addLines(voucherNo, List.of(debit, credit));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e);
}
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
}
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
.orElse(Map.of());
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, account_no, account_type, savings_account_id,
@@ -582,10 +684,64 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The deposit register raised its own voucher at submission and attaches the till and
// member sides to it on approval, so the savings command must not raise a second one over
// the same money -- exactly as the GL branch of this register already does below. Without
// this, one deposit appears twice on the cashier's received report and doubles the day's
// cash taken in.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
* until the instrument clears rather than let it sit withdrawable, so this places an automatic
* Clearing Hold for the full deposited amount right after a Cheque/DD deposit posts. Cash and
* electronic-transfer payment types (NEFT/RTGS/IMPS/UPI) are untouched -- cash is available on the
* spot, and an electronic transfer has already settled by the time it's entered here.
*/
private void placeClearingHoldIfInstrumentDeposit(final Long savingsAccountId, final Object paymentTypeId, final BigDecimal amount,
final String txnDateStr) {
if (paymentTypeId == null) {
return;
}
final Long ptId = ((Number) paymentTypeId).longValue();
// m_payment_type's display name column is "value", not "name" (the mistake that broke this
// in testing) -- name() collisions with other tables made this easy to get wrong once and not
// notice until it actually ran against the real schema.
final List<String> names = jdbcTemplate.query("SELECT value FROM m_payment_type WHERE id = ?",
(rs, rowNum) -> rs.getString("value"), ptId);
if (names.isEmpty()) {
return;
}
final String name = names.get(0).toLowerCase();
final boolean isInstrumentDeposit = name.contains("cheque") || name.contains("check") || name.equals("dd")
|| name.contains("demand draft");
if (!isInstrumentDeposit) {
return;
}
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
payload.addProperty("reasonForBlock", "Pending clearing — " + names.get(0) + " deposit");
payload.addProperty("holdCategory", "CLEARING");
payload.addProperty("lienAllowed", false);
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.holdAmount(savingsAccountId)
.withJson(payload.toString())
.build();
commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private String executeCurrentAccountDeposit(
final Long currentAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
@@ -610,7 +766,11 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// Suppressed for the same reason as the savings path above. Current accounts raise no
// voucher of their own today, so this changes nothing now; it is here so that the day one
// is added, this register does not silently start double-counting again.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -685,7 +845,10 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The deposit register raises its own voucher, naming the member and their account; the
// journal command must not raise a second one over the same money.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getTransactionId() != null ? result.getTransactionId() : null;
}
@@ -699,7 +862,7 @@ public class CashierDepositRequestApiResource {
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo,
final Long clientId, final Long cashierId, final String cashierUsername) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -729,12 +892,12 @@ public class CashierDepositRequestApiResource {
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
""",
requestId, savingsAccountId, accountNo, clientId, denomId,
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashierId, cashierUsername
cashierId, cashierUsername, paymentTypeName
);
}
}
@@ -23,12 +23,14 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
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.cashintercashier.domain.CashInterCashier;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -44,6 +46,10 @@ public class CashInterCashierApiResource {
private final CashInterCashierDetailRepository detailRepository;
private final PlatformSecurityContext context;
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();
@POST
@@ -85,6 +91,21 @@ public class CashInterCashierApiResource {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
"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);
CashInterCashier header = new CashInterCashier();
@@ -206,6 +227,72 @@ public class CashInterCashierApiResource {
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) {
for (JsonElement el : items) {
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.CashReconciliationDetailRepository;
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.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -89,6 +90,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "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 = ?) "
@@ -118,6 +120,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "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 = ?) "
@@ -259,6 +262,14 @@ public class CashReconciliationApiResource {
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? 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 physicalTotal = BigDecimal.ZERO;
List<CashReconciliationDetail> details = new ArrayList<>();
@@ -266,7 +277,10 @@ public class CashReconciliationApiResource {
for (JsonElement el : denominations) {
JsonObject item = el.getAsJsonObject();
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 varianceCount = physicalCount - expectedCount;
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
@@ -348,6 +362,30 @@ public class CashReconciliationApiResource {
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) {
for (Object[] row : rows) {
Long denomId = (Long) row[0];
@@ -13,8 +13,11 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -23,6 +26,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashreports")
@Component
@RequiredArgsConstructor
@Slf4j
@Produces({ MediaType.APPLICATION_JSON })
public class CashReportApiResource {
@@ -30,42 +34,566 @@ public class CashReportApiResource {
private final PlatformSecurityContext context;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Excludes counter breakdowns that must not count towards a drawer.
*
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
* breakdown behind and went on inflating the drawer for good. Every other leg of the cash
* position (vault transfers, settlements, inter-cashier legs, exchanges) already guards against
* its own reversal flag; this is the same guard for the counter, read off the transaction the
* breakdown belongs to.
*
* <p>
* Two things are excluded, and they are different failures:
*
* <ul>
* <li>a breakdown whose transaction is reversed -- the money came back, so the notes did too;
* <li>a breakdown keyed by a millisecond timestamp rather than a transaction id. Some earlier
* code wrote {@code System.currentTimeMillis()} into txn_id, leaving rows that belong to no
* transaction at all. This database holds ₹7.29 lakh of them, and because a cashier's position
* is cumulative they were being carried forward for ever: the Closing Cash Report opened Rohit
* Dubey's drawer at ₹9,99,106 when the teller's own GL held ₹8,070.
* </ul>
*
* <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* reads the same on all four.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
// such transactions in this database were carrying ₹1.68 lakh that never existed.
//
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
// everything counted after it) out of the cashier's position.
//
// Compared per transaction inside a correlated subquery rather than as a join, so a
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
/**
* Day book — every voucher raised on one date, in one list, newest first.
*
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
* without opening three separate screens.
*
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
* has no such column — falls back to the date it was submitted on.
*/
@GET
@Path("day-book")
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
@QueryParam("status") final String status) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final List<Map<String, Object>> rows = new ArrayList<>();
// Each register is left-joined to the verification table and to an attachment count, so a
// row already carries its physical-check verdict and whether a slip was scanned in — the
// screen never has to fire a follow-up call per voucher.
final String verifyJoin = """
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = r.voucher_no
""";
final String verifyCols = """
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
vv.verified_by_name, vv.verified_at,
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
COALESCE(va.n, 0) AS attachment_count
""";
// Cashier deposits — money in, so the credit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
-- Same rule as the withdrawal register below: an approved deposit is cash in
-- the drawer, a pending one is a request nobody has taken money for yet.
CASE WHEN r.status IN ('APPROVED', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_cashier_deposit_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Clerk withdrawals — money out, so the debit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
-- Filed by what happened to the money, not by who typed the request. Once the
-- notes are handed over it is the cashier's drawer that is short, so the row
-- belongs in their section; while it is still pending or was rejected no cash
-- has moved and it is the clerk's outstanding request.
CASE WHEN r.status IN ('WITHDRAWN', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
COALESCE(paid.officer_username, r.clerk_name) AS handled_by,
r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_withdrawal_request r
""" + verifyJoin + """
-- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it
-- is filed under whoever's cash actually moved. Sectioning it by the maker put
-- every counter payment in the clerk's column and left the cashier's own section
-- short by exactly what they had paid out -- the one figure their drawer is
-- checked against. The payer is read from the denomination breakdown, which is
-- written when the cash is handed over, and the By column names them too.
LEFT JOIN LATERAL (
SELECT d.officer_username FROM m_savings_txn_denomination d
WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT)
AND d.officer_username IS NOT NULL
LIMIT 1) paid ON TRUE
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Account closures — a full-balance payout, so the debit side. Its own register rather than
// the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever
// close() computes at approval) and the cash handed over is the ROUNDED
// cashier_disbursed_amount, which is what a cash book has to agree with, not the
// paisa-exact closing_amount posted to the member's ledger.
//
// Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the
// cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has
// taken nothing out of the drawer yet, and a REJECTED one never will — listing those would
// put rows with no cash behind them (a rejected closure has no amount at all) into the
// teller's day book. And the day they belong to is the day the cash left, so this filters on
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
// step to reach.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
-- 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,
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
r.payment_type_name, r.description, r.status,
COALESCE(r.disbursed_by, r.clerk_name) AS handled_by,
r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_closure_request r
""" + verifyJoin + """
WHERE r.status = 'CLOSED'
AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'CLERK' AS section,
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
r.description, r.status, r.clerk_name AS handled_by,
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_disbursement_request r
""" + verifyJoin + """
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Cash taken at the counter for a Demand Draft — a receipt, so the credit side.
//
// The Sponsor DD register is not one of the three above and has no voucher_no of its own, so
// the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a
// day book with an empty CR column and no sign of the money. The voucher is joined the other
// way round, from m_voucher.reference_id, which is where the link actually lives.
//
// Only the cash-funded drafts appear, and only those the cashier took the notes for
// directly: an account-debit or cheque draft never crosses the counter, and one funded from
// a cashier deposit request is already listed above under that register's own row.
rows.addAll(jdbcTemplate.query("""
SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind,
NULL AS account_no, 'GL' AS account_type,
COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party,
r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount,
'CASH' AS payment_type_name,
CONCAT('DD for ', r.beneficiary_name) AS description,
r.status, mk.username AS handled_by, ck.username AS approved_by,
CAST(r.id AS VARCHAR) AS txn_id,
r.created_date AS submitted_at, r.checker_date AS actioned_at,
""" + verifyCols + """
FROM m_sponsor_dd_issue_request r
LEFT JOIN m_appuser mk ON mk.id = r.maker_id
LEFT JOIN m_appuser ck ON ck.id = r.checker_id
LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST'
AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%'
""" + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """
WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Everything else that moved money on this date and is not one of the registers above:
// interest posted, a charge collected from a balance, a counter deposit or withdrawal, an
// escheat, a manual journal.
//
// Read from the voucher register rather than from another set of source tables, because
// that is the one place every posting lands whatever raised it — including the nightly jobs,
// which have no request row anywhere to read them from. Without this the officer's sheet
// showed only what somebody had filed a request for, and a day of system postings against
// members' accounts was invisible on the very page meant to account for the day.
//
// The registers above are excluded by reference type, so a voucher already listed as a
// deposit or a withdrawal request cannot appear twice under a different heading.
//
// Status is reported as COMPLETED: a voucher only exists once its posting has committed, so
// there is no pending state to represent — unlike a request, which can sit unactioned.
rows.addAll(jdbcTemplate.query("""
SELECT v.voucher_no,
-- A nightly batch belongs to the day it worked on, not the minute it ran.
--
-- A voucher's created_at is a wall-clock instant, while the transaction it
-- posts and the journal entry behind it both carry the business date. So the
-- interest job that fires at 00:10 stamps its vouchers with tomorrow while
-- the ledger says yesterday — and the day it actually belongs to had already
-- been counted, reported and signed off hours earlier.
--
-- Rather than re-open a closed day, such a voucher is listed on the day it
-- surfaces, under its own heading, saying which day's work it is. The date is
-- taken from the transaction it posts; where there is none — a manual journal
-- keyed straight to the ledger — an early-hours timestamp is treated the same
-- way, because nobody is at a counter at 3am.
CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
< CAST(v.created_at AS DATE)
OR (v.savings_txn_id IS NULL
AND EXTRACT(HOUR FROM v.created_at) < 6)
THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section,
-- Which day's work this is, for the heading on the prior-day block.
COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date,
CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side,
v.voucher_type AS kind,
-- The member's own account number, never a voucher line's — a line carries a
-- GL code there, and showing 500201 in an Account column reads as an account
-- that does not exist. A journal with no member behind it shows none.
sa.account_no,
NULL AS account_type,
COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party,
v.amount,
NULL AS payment_type_name,
-- The two sides in one phrase, which is what a journal voucher is read as.
CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description,
'COMPLETED' AS status,
v.created_by_username AS handled_by,
-- Who checked it, if anybody has. Not aliased from verifyCols below, which
-- selects verified_by_name under its own name for the verification panel --
-- the row mapper reads both columns, so each has to be present exactly once.
vv.verified_by_name AS approved_by,
CAST(v.savings_txn_id AS VARCHAR) AS txn_id,
v.created_at AS submitted_at, v.created_at AS actioned_at,
""" + verifyCols + """
FROM m_voucher v
-- Either link: savings_txn_id is stamped by a step after the voucher is written
-- and is empty on every interest posting, where reference_id already carries the
-- same transaction id. Without the fallback the prior-day test never fires on the
-- nightly postings it was written for.
LEFT JOIN m_savings_account_transaction t
ON t.id = COALESCE(v.savings_txn_id,
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
THEN v.reference_id END)
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
LEFT JOIN LATERAL (
SELECT party_name, account_no FROM m_voucher_line
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
ORDER BY line_no LIMIT 1) dr ON TRUE
LEFT JOIN LATERAL (
SELECT party_name, account_no FROM m_voucher_line
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
ORDER BY line_no LIMIT 1) cr ON TRUE
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = v.voucher_no
WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE)
AND (v.reference_type IS NULL OR v.reference_type NOT IN (
'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST',
'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST',
'SPONSOR_DD_ISSUE_REQUEST'))
""", this::mapDayBookRow, txnDate));
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
// ordered as one result set, and the row count for a single day is small.
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
final List<Map<String, Object>> filtered = new ArrayList<>();
BigDecimal creditTotal = BigDecimal.ZERO;
BigDecimal debitTotal = BigDecimal.ZERO;
// 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) {
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
continue;
}
filtered.add(row);
// Only approved money is real money: a pending or rejected voucher must not inflate the
// day's totals, even though it still belongs in the listing. The source registers don't
// share one terminal status string: deposits and disbursements land on APPROVED, a clerk
// withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing
// any of them meant a completed transaction moved real money out yet never showed up in
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
// both states cannot double-count it; only 'CLOSED' is unique to that register.
final String rowStatus = (String) row.get("status");
// A batch that posted against yesterday is listed today but is not today's money: it
// 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");
if (amount != null) {
if ("CR".equals(row.get("side"))) {
creditTotal = creditTotal.add(amount);
} else {
debitTotal = debitTotal.add(amount);
}
}
}
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) -> {
final Object left = b.get("submittedAt");
final Object right = a.get("submittedAt");
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.toString().compareTo(right.toString());
});
final Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", filtered);
result.put("count", filtered.size());
// Counter figures: the credit and debit the customer side of the day adds up to.
result.put("creditTotal", creditTotal);
result.put("debitTotal", 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);
}
/**
* 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 {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no"));
// Which part of the day's work this row belongs to. The registers do not divide along
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
// where the entry came from, not who typed it.
row.put("section", rs.getString("section"));
// Which day's work the row is: normally the day being reported, but a nightly batch posts
// against the previous business date while surfacing on this one. Only the SYSTEM query
// selects it, so it is read defensively rather than added to five other queries that would
// all report the same date they were asked for.
row.put("effectiveDate", optionalDate(rs, "effective_date"));
row.put("side", rs.getString("side"));
row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no"));
row.put("accountType", rs.getString("account_type"));
row.put("party", rs.getString("party"));
row.put("amount", rs.getBigDecimal("amount"));
row.put("paymentTypeName", rs.getString("payment_type_name"));
row.put("description", rs.getString("description"));
row.put("status", rs.getString("status"));
row.put("handledBy", rs.getString("handled_by"));
row.put("approvedBy", rs.getString("approved_by"));
row.put("txnId", rs.getString("txn_id"));
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
row.put("verifyStatus", rs.getString("verify_status"));
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
row.put("verifyRemarks", rs.getString("verify_remarks"));
row.put("verifiedByName", rs.getString("verified_by_name"));
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
// How the exception ended, if it has. Without this the day book keeps showing a closed
// exception as though it were still open — the flag never clears on screen.
row.put("resolutionStatus", rs.getString("resolution_status"));
row.put("resolutionNotes", rs.getString("resolution_notes"));
row.put("resolvedByName", rs.getString("resolved_by_name"));
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
row.put("attachmentCount", rs.getInt("attachment_count"));
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
// form the browser cannot parse reliably.
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
return row;
}
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// Single query — conditional aggregation for deposit, withdrawal, net 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, "
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
+ "ORDER BY std.denomination_value DESC, dm.kind";
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
// Every note and coin that reached the drawer or left it, per denomination.
//
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
// counted against. So the vault allocation and the settlement back are included on the same
// footing as the counter work, and each row says where its notes came from.
//
// Cashier scoping differs per leg because each table records the party differently: the
// counter tables name the acting user, the cash tables name the cashier row.
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
// Counter work: what customers handed over and were paid.
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
// Drawn from the vault in the morning.
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
// Handed back to the vault at close.
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
long depositCount = rs.getLong("deposit_count");
long withdrawalCount = rs.getLong("withdrawal_count");
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
final long vaultInCount = rs.getLong("vault_in_count");
final long depositCount = rs.getLong("deposit_count");
final long withdrawalCount = rs.getLong("withdrawal_count");
final long settledOutCount = rs.getLong("settled_out_count");
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount);
row.put("depositAmount", depositAmount);
row.put("withdrawalCount", withdrawalCount);
row.put("withdrawalAmount", withdrawalAmount);
row.put("netCount", depositCount - withdrawalCount);
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
row.put("settledOutCount", settledOutCount);
row.put("settledOutAmount", settledOutAmount);
// What is left of this denomination: everything that came in, less everything that went
// out. This is the figure a teller counts their notes against, per note.
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount",
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
return row;
}, txnDate);
}, args.toArray());
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -75,6 +603,7 @@ public class CashReportApiResource {
}
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
final String exchangeCashierFilter = scopedCashierId != null ? "AND ce.cashier_id = ? " : "";
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
@@ -84,10 +613,16 @@ public class CashReportApiResource {
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
+ "ORDER BY ced.denomination_value DESC, dm.kind";
final List<Object> exArgs = new ArrayList<>();
exArgs.add(txnDate);
if (scopedCashierId != null) {
exArgs.add(scopedCashierId);
}
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
long receivedCount = rs.getLong("received_count");
long givenCount = rs.getLong("given_count");
@@ -104,18 +639,135 @@ public class CashReportApiResource {
row.put("netCount", receivedCount - givenCount);
row.put("netAmount", receivedAmount.subtract(givenAmount));
return row;
}, txnDate);
}, exArgs.toArray());
// Cash taken over the counter for a Demand Draft.
//
// A cash DD asks for a denomination breakdown but does not insist on one, so most are
// recorded with none at all -- and the whole of this report is built from breakdowns. The
// money is real: the ledger holds it (Cash at Vault is debited when the notes are taken)
// and the cashier is holding the notes, yet the drawer figure omitted every rupee of it.
//
// Counted from the request itself rather than from a breakdown, so it appears whether or not
// the notes were itemised. Excluded are DDs funded through a cashier deposit request: that
// register counted the same notes already, and adding them here would count them twice.
final String ddCashierFilter = scopedCashierId != null ? "AND dd.maker_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
final String ddSql = "SELECT COALESCE(SUM(dd.face_value + COALESCE(dd.charges_amount, 0) "
+ "+ COALESCE(dd.gst_amount, 0)), 0) AS cash_total, COUNT(*) AS cash_count "
+ "FROM m_sponsor_dd_issue_request dd "
+ "WHERE dd.payment_method = 'CASH' AND dd.cashier_deposit_request_id IS NULL "
+ "AND dd.status <> 'REJECTED' "
+ "AND COALESCE(dd.issued_date, CAST(dd.created_date AS DATE)) = CAST(? AS DATE) " + ddCashierFilter;
final List<Object> ddArgs = new ArrayList<>();
ddArgs.add(txnDate);
if (scopedCashierId != null) {
ddArgs.add(scopedCashierId);
}
BigDecimal ddCashTotal = BigDecimal.ZERO;
long ddCashCount = 0;
try {
final Map<String, Object> ddRow = jdbcTemplate.queryForMap(ddSql, ddArgs.toArray());
ddCashTotal = (BigDecimal) ddRow.get("cash_total");
ddCashCount = ((Number) ddRow.get("cash_count")).longValue();
} catch (final RuntimeException e) {
// A tenant without the Sponsor DD tables still gets its closing report.
log.warn("Could not read cash Demand Draft receipts for {}", txnDate, e);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", rows);
// The denomination-wise total, which is what the rows below add up to.
result.put("depositTotal", depositTotal);
result.put("withdrawalTotal", withdrawalTotal);
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
// Reported separately as well, so a screen can show where the difference comes from rather
// than presenting a deposit total its own rows do not explain.
result.put("ddCashTotal", ddCashTotal);
result.put("ddCashCount", ddCashCount);
result.put("depositTotalWithDd", depositTotal.add(ddCashTotal));
result.put("netTotal", depositTotal.add(ddCashTotal).subtract(withdrawalTotal));
result.put("exchangeRows", exchangeRows);
result.put("cashierId", scopedCashierId);
result.put("scopedToCashier", scopedCashierId != null);
// Opening and closing cash in the drawer.
//
// netTotal above is only the day's counter movement, which is not what the cashier is
// holding. A closing figure has to carry the previous balance forward, so it is derived from
// the cumulative position -- vault allocations, settlements, inter-cashier legs and
// exchanges as well as the counter work -- read once as of the day before and once as of the
// day itself. The gap between the two is the day's entire cash movement, a wider figure than
// deposits minus withdrawals, which is why both are reported rather than one derived from
// the other: when they differ, the difference is vault and inter-cashier traffic.
//
// Only a cashier-scoped call can answer this. A branch-wide opening balance belongs to the
// branch day register, not to this report.
if (scopedCashierId != null) {
final BigDecimal openingBalance = positionTotalAsOf(scopedCashierId,
LocalDate.parse(txnDate).minusDays(1).toString());
final BigDecimal closingBalance = positionTotalAsOf(scopedCashierId, txnDate);
result.put("openingBalance", openingBalance);
result.put("closingBalance", closingBalance);
result.put("cashMovement", closingBalance.subtract(openingBalance));
// The day on its own terms, which is what a cashier actually reconciles.
//
// A cashier does not carry a balance: they draw cash from the vault in the morning, work
// the counter, and hand it back at close. So the cumulative figures above answer a
// question nobody at the counter asks — and one carrying years of history answers it
// with a number that cannot be counted against anything on the desk. These four are the
// day itself: taken from the vault, in over the counter, out over the counter, settled
// back. What is left is what should physically be in the drawer.
result.put("todayVaultIn", dayLegTotal(
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ct.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todaySettledOut", dayLegTotal(
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
+ " AND COALESCE(cs.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierIn", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierOut", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
}
return gson.toJson(result);
}
/** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */
private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) {
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate);
return total == null ? BigDecimal.ZERO : total;
}
/**
* What one cashier is holding at the end of a given date, in rupees.
*
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO;
}
@GET
@Path("excess")
public String excess(@QueryParam("threshold") BigDecimal threshold,
@@ -360,6 +1012,30 @@ public class CashReportApiResource {
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
/**
* GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the
* bank have", as opposed to cashier-position which answers the same question for a cashier.
* See {@link VaultPositionService} for how the anchor is chosen.
*/
@GET
@Path("vault-position")
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
@QueryParam("asOfDate") final String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
if (tellerId == null) {
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
final List<Map<String, Object>> all = new ArrayList<>();
for (final Long id : tellerIds) {
all.add(vaultPositionService.buildPositionForVault(id, dateFilter));
}
return gson.toJson(all);
}
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
@@ -414,7 +1090,17 @@ public class CashReportApiResource {
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
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 "
@@ -438,7 +1124,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "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 = ?) " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
@@ -464,7 +1150,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "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 = ?) " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
@@ -597,9 +1283,44 @@ public class CashReportApiResource {
baseParams = new Object[] { txnDate };
}
// Base grouped query (reused for count, totals, and paged data)
// Base grouped query (reused for count, totals, and paged data). The two LEFT JOINs resolve
// the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/
// withdrawal request at approval time -- std.txn_id on its own is only the internal savings
// transaction id (e.g. "4"), never what was actually printed on the voucher.
//
// A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry
// reference) rather than a plain decimal savings-transaction id, but std.txn_id is always
// stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the
// hex string's numeric value (178807899015311), not the hex string itself, and a plain
// string-equality join against cdr.txn_id can never match it. The second half of each OR
// below re-derives that same decimal value from the hex string to match it -- guarded to
// only run on strings that are actually hex (so an ordinary decimal txn_id, which never
// contains a-f, is never sent through the hex cast at all).
final String baseGroupSql = "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
// A GL-type row (no client_id) carries the GL account's own code in account_no
// rather than a savings/current account number, so its "name" has to come from the
// chart of accounts, not from a client that was never party to the transaction.
+ "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no "
// Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's
// decimal txn_id can coincidentally hex-decode to the exact same number (confirmed
// live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to
// 178810369551311), and an unscoped join matched the withdrawal row to that unrelated
// deposit's voucher, which COALESCE then preferred over the withdrawal's own real one.
+ "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND "
+ " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND "
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
// An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row
// (the column records which way cash moved), but its request lives in its own
// register -- without this join the closure's real voucher number is never found and
// the report falls back to showing the bare savings-transaction id instead.
+ "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND "
+ " ccr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ officerFilter
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
@@ -618,20 +1339,42 @@ public class CashReportApiResource {
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
// 2. Paged data query
final String dataSql = "SELECT "
+ " std.txn_id AS voucher_no, "
// 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
// 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), "
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " std.account_no AS ac_no, "
+ " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, "
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
+ " MAX(std.txn_date) AS txn_date, "
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
+ " MAX(std.created_at) AS sort_key, "
+ " MAX(std.officer_username) AS teller_name, "
+ " MAX(std.payment_type) AS payment_type, "
+ " 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 = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
+ baseGroupSql
+ "ORDER BY MAX(std.created_at) "
+ baseGroupSql;
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 ?";
// Append limit and offset to params
@@ -642,7 +1385,7 @@ public class CashReportApiResource {
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getLong("voucher_no"));
row.put("voucherNo", rs.getString("voucher_no"));
row.put("acNo", rs.getString("ac_no"));
row.put("acName", rs.getString("ac_name"));
row.put("txnDate", rs.getString("txn_date"));
@@ -652,6 +1395,12 @@ public class CashReportApiResource {
row.put("txnSource", rs.getString("txn_source"));
row.put("dr", rs.getBigDecimal("dr_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;
}, pagedParams);
@@ -0,0 +1,181 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cashreport.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
/**
* Denomination-wise cash a vault (teller) is currently holding.
*
* Anchored on the most recent business day (on or before the target date) whose branch day
* register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact
* teller), then walked forward by everything settled into this vault and handed out of it since.
* Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows
* (which mix the vault with whatever every cashier under it is holding) precisely so this anchor
* never includes cash that still physically sits with a cashier — mixing the two double-counts
* whatever a cashier settles back later, once already folded into the branch total, again as the
* settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT}
* row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor
* still returns something rather than zero.
*/
@Component
@RequiredArgsConstructor
public class VaultPositionService {
private final JdbcTemplate jdbcTemplate;
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final RowMapper<Object[]> DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"),
rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") };
public Map<String, Object> buildPositionForVault(final Long tellerId, final String dateFilter) {
final Map<String, Object> result = new LinkedHashMap<>();
result.put("tellerId", tellerId);
result.put("asOfDate", dateFilter);
String tellerName = null;
Long officeId = null;
try {
final Map<String, Object> teller = jdbcTemplate.queryForMap("SELECT name, office_id FROM m_tellers WHERE id = ?",
tellerId);
tellerName = (String) teller.get("name");
officeId = ((Number) teller.get("office_id")).longValue();
} catch (final Exception ignored) {
// intentionally ignored
}
result.put("tellerName", tellerName);
if (officeId == null) {
result.put("hasBreakdown", false);
result.put("totalAmount", BigDecimal.ZERO);
result.put("denominations", new ArrayList<>());
return result;
}
// Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried
// forward day to day, with no cashier's held cash folded in. Fall back to the old
// branch-combined OPENING leg only when this office has no vault-scoped row at all on or
// before the target date (a day closed before vault-only tracking existed).
List<Map<String, Object>> anchorRows = jdbcTemplate.queryForList("""
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
WHERE d.register_id = r.id AND d.leg = 'OPENING_VAULT' AND d.teller_id = ?)
ORDER BY r.business_date DESC LIMIT 1
""", officeId, dateFilter, tellerId);
String anchorLeg = "OPENING_VAULT";
if (anchorRows.isEmpty()) {
// The branch-total OPENING leg mixes the vault with whatever every cashier under it
// holds, so it is only a defensible stand-in for a vault when the office has exactly one
// teller. With two, EVERY teller without a vault-scoped row of its own would anchor on
// the same office-wide breakdown, and the frontend's Branch Total -- which sums each
// teller's vault -- would report roughly twice the branch's actual cash. Worse, a close
// then snapshots that inflated figure as this teller's CLOSING_VAULT and the next open
// carries it forward, making the phantom permanent. A second teller with no history of
// 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()) {
result.put("hasBreakdown", false);
result.put("totalAmount", BigDecimal.ZERO);
result.put("denominations", new ArrayList<>());
return result;
}
final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue();
final String anchorDate = anchorRows.get(0).get("business_date").toString();
final Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query("""
SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count
FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id
WHERE d.register_id = ? AND d.leg = ? AND (d.teller_id IS NULL OR d.teller_id = ?)
""", DENOM_ROW, registerId, anchorLeg, tellerId), 1);
addToBalance(balance, jdbcTemplate.query("""
SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count
FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id
WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)
AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE)
GROUP BY csd.denomination_id, csd.denomination_value, dm.kind
""", DENOM_ROW, tellerId, anchorDate, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query("""
SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count
FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id
WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE)
GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind
""", DENOM_ROW, tellerId, anchorDate, dateFilter), -1);
final List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])).forEach(e -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("count", e.getValue()[2]);
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
final BigDecimal totalAmount = rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO,
BigDecimal::add);
result.put("hasBreakdown", true);
result.put("anchorDate", anchorDate);
result.put("totalAmount", totalAmount);
result.put("denominations", rows);
return result;
}
private void addToBalance(final Map<Long, long[]> balance, final List<Object[]> rows, final int sign) {
for (final Object[] row : rows) {
final Long denomId = (Long) row[0];
final int denomValue = (Integer) row[1];
final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
final long count = (Long) row[3];
final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
}
@@ -22,7 +22,9 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -31,6 +33,8 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
@@ -41,6 +45,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashsettlement")
@Component
@RequiredArgsConstructor
@Slf4j
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashSettlementApiResource {
@@ -52,9 +57,22 @@ public class CashSettlementApiResource {
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final VoucherService voucherService;
// The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report
// says they are not holding.
private final CashReportApiResource cashReportApiResource;
private final Gson gson = new Gson();
// 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
@Transactional
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
@@ -107,6 +125,8 @@ public class CashSettlementApiResource {
"At least one denomination with a positive count is required.");
}
validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate);
final BigDecimal total = sumTotal(denominations);
CashSettlement header = new CashSettlement();
@@ -285,6 +305,67 @@ public class CashSettlementApiResource {
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) {
for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
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.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
@@ -52,6 +53,9 @@ public class CashTransferApiResource {
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
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();
@POST
@@ -98,6 +102,8 @@ public class CashTransferApiResource {
"At least one denomination with a positive count is required.");
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
header.setFromParty(fromParty);
@@ -264,6 +270,120 @@ public class CashTransferApiResource {
return total;
}
/**
* A vault can only hand out cash it is actually holding — in total, and note-by-note. The
* branch's business day carries an opening_cash figure forward each day (see
* BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is
* that opening figure, plus anything a cashier has already settled back into this same vault
* today, minus whatever this vault has already handed out to any cashier today. Reversed
* transfers/settlements don't count.
*
* No open business day for the vault's office means there is no opening_cash to check against, so
* the transfer is blocked outright rather than silently allowed against an unknown balance.
*/
private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount,
final JsonArray denominations, final LocalDate transferDate) {
final Long officeId;
try {
officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId);
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found",
"Vault/teller " + fromTellerId + " was not found.");
}
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
final List<Long> registerIds = jdbcTemplate.query("""
SELECT id FROM m_branch_day_register
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
""", (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate);
if (registerIds.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open",
"The business day for this branch is not open for " + businessDate
+ ". Open the business day before allocating cash to a cashier.");
}
final Long registerId = registerIds.get(0);
final BigDecimal openingCash = jdbcTemplate.queryForObject(
"SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId);
final BigDecimal handedOutToday = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
final BigDecimal settledBackToday = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday);
if (requestedAmount.compareTo(available) > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash",
"Insufficient vault cash: available " + available + ", requested " + requestedAmount + ".");
}
validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate);
}
/**
* The total-amount check above can pass while a specific note/coin still runs short — a vault
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
* total is well within its balance. This checks each requested denomination against what the
* vault actually holds of it.
*
* <p>
* The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen
* 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,
final JsonArray denominations, final java.sql.Date businessDate) {
final Map<String, Object> position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString());
if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
return;
}
final Map<Long, Integer> available = new java.util.HashMap<>();
@SuppressWarnings("unchecked")
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows != null) {
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) {
available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum);
}
}
}
for (final JsonElement el : denominations) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
continue;
}
final long denominationId = item.get("denominationId").getAsLong();
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
final int availableCount = available.getOrDefault(denominationId, 0);
if (count > availableCount) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination",
"Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount
+ ", requested " + count + ".");
}
}
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
@@ -0,0 +1,970 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
*
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
* flow.
*
* <p>
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
*
* <pre>
* Dr Cash at Vault (SHARES_REFERENCE) = amount
* Cr Share Sundry (SHARES_SUSPENSE) = amount
* </pre>
*
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
*
* <p>
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
*
* <pre>
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
* Cr Share Capital (SHARES_EQUITY) = share amount
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
* </pre>
*
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
*
* <p>
* <b>When the cash does not match</b>
*
* <p>
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
* argued with at the window:
*
* <ul>
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
* back to them.</li>
* </ul>
*/
@Path("/v1/shareadvancecollection")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareAdvanceCollectionApiResource {
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
// ── m_share_account.status_enum ───────────────────────────────────────
private static final int STATUS_ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// POST — Take cash from a member with no share account yet
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response collect(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long clientId = getLong(json, "clientId");
final BigDecimal amount = getBigDecimal(json, "amount");
final String note = sanitiseNote(getString(json, "note"));
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
}
final Map<String, Object> client = loadClient(clientId);
final Long officeId = toLong(client.get("office_id"));
final String clientName = (String) client.get("display_name");
// The share product drives the GL mapping. Collection happens before an account exists,
// so the product is supplied by the caller or defaults to the only mapped share product.
final Long productId = resolveProductId(getLong(json, "productId"));
final String currencyCode = resolveCurrency(productId);
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHADV-" + System.nanoTime();
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
"Share advance held in Sundry | Receipt " + receiptNo);
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
currentUser.getUsername(), note);
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
receiptNo);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", id);
out.put("receiptNo", receiptNo);
out.put("clientId", clientId);
out.put("clientName", clientName);
out.put("amount", amount);
out.put("currency", currencyCode);
out.put("status", "UNAPPLIED");
out.put("glTransactionId", txnId);
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
// firewall with a 400 before the request reaches this resource. The older
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
@GET
@Path("/by-voucher")
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
@QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) {
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
"A voucher or receipt number is required.");
}
return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride);
}
@GET
@Path("/by-voucher/{voucherNo}")
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint,
@QueryParam("glAccountNo") final String glAccountNoOverride) {
this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
if (rows.isEmpty()) {
// Cash is normally taken through the cashier's Transaction Entry deposit into the GL
// account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt
// that approved deposit as a collection so the member's voucher resolves here. Which GL
// account to look in is never guessed from product configuration — the counter selects it
// by hand every time, since a product mapping can silently go missing or point at the
// wrong account and a plain voucher lookup should never depend on that being right.
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) {
return Response.ok(gson.toJson(adopted)).build();
}
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
+ voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the "
+ "deposit has been approved by a manager.");
}
final Map<String, Object> row = rows.get(0);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
}
return Response.ok(gson.toJson(row)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — GL accounts selectable for a by-voucher lookup
// ══════════════════════════════════════════════════════════════════════
/**
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
*
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
* applying a receipt picks the account the deposit actually went to, every time.
*/
@GET
@Path("/glaccounts")
public Response glAccounts() {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — What a share account needs before a collection can be applied
// ══════════════════════════════════════════════════════════════════════
/**
* Share value plus any outstanding entry fee for one share account -- shown up front on the Apply
* Collection screen so the person at the counter knows the full amount to collect (and that it may
* come in as more than one receipt, e.g. one for the share value and a separate one for the fee)
* before a search ever comes up short, rather than learning it only from an error after the fact.
*/
@GET
@Path("/requirement")
public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
final Map<String, Object> acct = loadAccount(shareAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("accountNo", acct.get("account_no"));
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("required", shareAmount.add(chargeAmount));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{collectionId}/apply")
@Transactional
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long shareAccountId = getLong(json, "shareAccountId");
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
// A member who pays in instalments has more than one receipt against the same application:
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
// are applied together as one settlement. The path receipt leads; any others come in the
// body. These are the SHARE-side receipts only -- money that actually sat in Sundry and
// genuinely needs to move out of it into Share Capital.
final List<Long> collectionIds = new java.util.ArrayList<>();
collectionIds.add(collectionId);
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
if (!collectionIds.contains(additionalId)) {
collectionIds.add(additionalId);
}
}
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
for (final Long id : collectionIds) {
final Map<String, Object> row = loadCollection(id);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
}
collections.add(row);
}
Map<String, Object> chargeColl = null;
if (chargeCollectionId != null) {
chargeColl = loadCollection(chargeCollectionId);
if (!"UNAPPLIED".equals(chargeColl.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + ".");
}
}
final Map<String, Object> coll = collections.get(0);
final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move
// one member's money into another's share capital. Checked per receipt, since a settlement
// can now carry several.
final Long acctClientId = toLong(acct.get("client_id"));
for (final Map<String, Object> row : collections) {
if (!toLong(row.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
}
}
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
}
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
}
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
"Share account " + acct.get("account_no") + " has already been settled.");
}
// Share value the account was opened for, and any entry fee attached to it.
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
// When the fee is being verified as its own receipt, the Sundry-side receipts only need to
// cover the share value -- the fee is proved paid separately below, and its money is never
// expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is
// assumed bundled into the same Sundry receipt(s) as the share value, exactly as before.
final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount);
BigDecimal receiptAmount = BigDecimal.ZERO;
final StringBuilder receiptNos = new StringBuilder();
for (final Map<String, Object> row : collections) {
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
if (receiptNos.length() > 0) {
receiptNos.append(" + ");
}
receiptNos.append(row.get("receipt_no"));
}
// Short is blocked: share capital cannot be part-issued — the member either bought those
// shares or did not — so settling for less would leave the account short by an amount owed
// by nobody. The clerk collects the balance on another receipt and applies both together.
if (receiptAmount.compareTo(requiredForShareReceipts) < 0) {
final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
+ requiredForShareReceipts + ". Collect the remaining " + shortfall
+ " from the member, then apply both receipts together.");
}
// The fee receipt, when given, must cover the fee on its own -- checked separately from the
// share-side shortfall above, since the two are no longer one combined pot.
if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount");
if (chargeReceiptAmount.compareTo(chargeAmount) < 0) {
final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short",
"Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account "
+ acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining "
+ shortfall + " from the member.");
}
}
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
// The difference is credited to their linked savings account below. Only the share-side
// excess is handled here -- an over-payment on a separately-verified fee receipt would need
// its own GL leg to fund the savings credit, which this flow does not post (that receipt's
// money is left exactly where the cashier already put it), so it is left uncollected rather
// than silently unbalancing the ledger.
final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts);
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
+ " requires only " + requiredForShareReceipts + ". The extra " + excessAmount
+ " must be credited to the member's savings account, but none is linked to this share account. Link one, or "
+ "collect the exact amount.");
}
final Long officeId = toLong(acct.get("office_id"));
final Long productId = toLong(acct.get("product_id"));
final String currencyCode = (String) acct.get("currency_code");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
final String txnId = "SHAPP-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no");
// Dr Sundry for the share-side receipts, including any over-payment on them — the liability
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
// it was never in Sundry, so nothing here should debit Sundry for it.
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
// income account itself), so moving it again here would double it. When it was bundled into
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
// to move, exactly as before.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
if (chargeColl == null) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
}
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
}
// The over-payment goes back to the member as a savings deposit rather than a bare journal
// entry, so it lands in their passbook and running balance the way any counter deposit
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
Long excessSavingsTransactionId = null;
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
}
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
// Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit
// table regardless of which GL account they were posted to, so no GL account lookup is needed
// here either. Covers the fee receipt's own deposit too, when one was verified.
for (final Map<String, Object> row : collections) {
if (row.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"));
}
}
if (chargeColl != null && chargeColl.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no"));
}
// Every receipt in this settlement is marked applied, not just the one in the path — they
// were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
// receipt is marked applied too, even though it posted no GL entry of its own.
for (final Long id : collectionIds) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
}
if (chargeCollectionId != null) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
}
// Reveals the purchase transaction in the passbook and transactions views.
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", collectionId);
out.put("collectionIds", collectionIds);
out.put("receiptNo", receiptNos.toString());
out.put("shareAccountId", shareAccountId);
out.put("accountNo", accountNo);
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("chargeReceiptVerifiedSeparately", chargeColl != null);
out.put("totalApplied", requiredForShareReceipts);
out.put("totalCollected", receiptAmount);
out.put("glTransactionId", txnId);
out.put("status", "APPLIED");
// Told at the counter, while the member is still there, rather than left to be noticed on
// a statement later.
out.put("excessAmount", excessAmount);
if (excessSavingsTransactionId != null) {
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
+ "their savings account.");
}
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
*
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
* so the voucher resolves and can be applied.
*
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
* is what keeps the money tied to the right member.
*
* @param glAccountNoOverride
* required -- the GL account number the deposit was posted to, selected by the counter
* rather than resolved from any product's accounting configuration, so a broken or
* missing product mapping can never make a genuine voucher unreachable.
* @return the newly created collection row, or null when no approved deposit matches.
*/
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
"Select the GL account this voucher was deposited to, then search again.");
}
final String shareSundryAccountNo = glAccountNoOverride.trim();
final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ " FROM m_cashier_deposit_request d"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, shareSundryAccountNo);
if (deposits.isEmpty()) {
return null;
}
final Map<String, Object> dep = deposits.get(0);
if (toLong(dep.get("share_account_id")) != null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// Prefer the member recorded on the deposit; fall back to the account being settled.
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
+ " so it cannot be matched. Re-enter the deposit against the member.");
}
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
"Adopted from cashier deposit voucher " + voucherNo);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
/**
* 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) {
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
}
/** Share value the account was opened for: the pending/approved purchase transaction. */
private BigDecimal shareValueOf(final Long shareAccountId) {
final BigDecimal total = jdbcTemplate
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
/** Entry fee still outstanding on the account, zero when no charge was attached. */
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
// created before the flat-charge fix carry the flag set to true while the full amount is
// still outstanding, and those charges must still be settled by the collection.
final BigDecimal total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
private Map<String, Object> loadCollection(final Long id) {
try {
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
"Share advance collection " + id + " does not exist.");
}
}
private Map<String, Object> loadClient(final Long clientId) {
try {
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
}
}
private Map<String, Object> loadAccount(final Long shareAccountId) {
try {
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
"Share account " + shareAccountId + " does not exist.");
}
}
/**
* Uses the caller's product when given, else the single mapped share product.
*
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
* when only one real share product is mapped.
*/
private Long resolveProductId(final Long requested) {
if (requested != null) {
return requested;
}
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
if (ids.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
"No share product has GL mappings configured.");
}
if (ids.size() > 1) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
"productId is required: " + ids.size() + " share products have GL mappings.");
}
return ids.get(0);
}
private String resolveCurrency(final Long productId) {
try {
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
productId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (IncorrectResultSizeDataAccessException e) {
return null;
}
}
private String nextReceiptNo() {
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
return String.format("SHADV%06d", seq == null ? 1 : seq);
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
}
}
private LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
try {
return LocalDate.parse(raw);
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
}
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long getLong(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
}
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
}
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
private List<Long> getLongList(final JsonObject json, final String field) {
final List<Long> values = new java.util.ArrayList<>();
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
return values;
}
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
if (!element.isJsonNull()) {
values.add(element.getAsLong());
}
}
return values;
}
/**
* Credits an over-payment to the member's savings account.
*
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
* would move the balance in the ledger while leaving the member's own account history silent about it.
*/
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
}
/**
* The payment type this deposit is recorded under.
*
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
*/
private Long resolveExcessPaymentTypeId() {
final List<Long> byName = jdbcTemplate.queryForList(
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
if (!byName.isEmpty()) {
return byName.get(0);
}
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
if (any.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
}
return any.get(0);
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number ? ((Number) o).intValue() : 0;
}
}
@@ -21,6 +21,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -360,6 +361,7 @@ public class ShareCollectionApiResource {
final LocalDate entryDate, final Long shareAccountId,
final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry "
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
@@ -375,8 +377,20 @@ public class ShareCollectionApiResource {
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
toUtc(now),
toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private Map<String, Object> glEntryMap(final String account, final String type,
@@ -35,6 +35,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.charge.domain.Charge;
@@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService {
private final FineractRequestContextHolder fineractRequestContextHolder;
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
private final SavingsHelper savingsHelper;
private final PlatformSecurityContext context;
private final VoucherService voucherService;
/** A cheque book issuance charge, keyed by its m_cheque_book id. */
private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE";
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
private FacilityChargeMapping resolveMapping() {
@@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService {
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId);
}
@@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService {
this.currentAccountRepository.saveAndFlush(account);
}
/**
* Records a cheque book issuance charge in the voucher register.
*
* The account is debited and the charge's income account credited — the society's fee, taken
* from the customer. The debit names the customer and their account number even though it posts
* against the product's control GL, so the slip reads as the customer's own.
*
* A failure here does not fail the charge. The money is already taken and the ledger already
* posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The
* problem is logged and the charge stands.
*/
private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId,
final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) {
try {
final Map<String, Object> holder = accountHolderForVoucher(accountType, accountId);
final Map<String, Object> incomeGl = this.voucherService.glAccount(incomeGlId);
final String narration = "Cheque book issuance charge - book #" + chequeBookId;
this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId,
this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT,
List.of(VoucherLine
.debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount)
.withDescription(narration),
VoucherLine
.creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount)
.withDescription(narration)));
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e);
}
}
/** The customer and account number a charge is taken from, for printing on a voucher. */
private Map<String, Object> accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) {
final String sql = accountType == ChequeAccountType.SAVINGS
? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"
: "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
private Long getSavingsControlGlId(final Long savingsAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
@@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService {
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
// The charge raises its own voucher, naming the account charged and the fee; the journal
// command must not raise a second one over the same money.
final CommandProcessingResult result = this.voucherService
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
if (result == null) {
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
return;
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.clearing.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing
* role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are
* fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads
* them and updates which GL account one points at.
*/
@Path("/v1/clearingglconfig")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ClearingGlConfigApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code
FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id
ORDER BY c.config_key
""");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toResult(row));
}
return gson.toJson(out);
}
@GET
@Path("template")
public String template() {
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT id, name, gl_code FROM acc_gl_account
WHERE disabled = false AND manual_journal_entries_allowed = true
ORDER BY name
""");
final List<Map<String, Object>> options = new ArrayList<>();
for (final Map<String, Object> a : accounts) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", a.get("id"));
o.put("name", a.get("name"));
o.put("glCode", a.get("gl_code"));
options.add(o);
}
return gson.toJson(Map.of("glAccountOptions", options));
}
@PUT
@Path("{configKey}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("configKey") final String configKey, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG");
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?",
Integer.class, configKey);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found",
"Clearing GL config key '" + configKey + "' was not found.");
}
final var root = this.fromJsonHelper.parse(body);
final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root);
if (glAccountId != null) {
final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?",
Integer.class, glAccountId);
if (accountExists == null || accountExists == 0) {
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found",
"GL account " + glAccountId + " was not found.");
}
}
jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId,
configKey);
return retrieveAll();
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("id", row.get("id"));
out.put("configKey", row.get("config_key"));
out.put("description", row.get("description"));
out.put("glAccountId", row.get("gl_account_id"));
out.put("glAccountName", row.get("gl_account_name"));
out.put("glCode", row.get("gl_code"));
return out;
}
}
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
@@ -32,6 +33,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -49,6 +53,11 @@ public class ClearingJournalService {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final VoucherService voucherService;
/** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */
private static final String REF_CLEARING_POSTING = "CLEARING_POSTING";
/**
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
@@ -57,6 +66,19 @@ public class ClearingJournalService {
*/
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null);
}
/**
* As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT.
*
* Savings and current accounts number independently, so the same id exists in both tables and the id
* alone cannot say whose account it is. A caller that knows the type passes it and the voucher names
* the right customer; one that has no account at all — a settlement summary — passes neither.
*/
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId,
final String linkAccountType) {
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
creditGlId, amount);
@@ -86,7 +108,10 @@ public class ClearingJournalService {
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
// Clearing raises its own voucher below, naming the customer account the item belongs to;
// the journal command must not raise a second one over the same money.
final CommandProcessingResult result = this.voucherService
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
if (result == null) {
log.warn("Clearing journal entry did not return a result ({}).", comments);
return null;
@@ -95,9 +120,77 @@ public class ClearingJournalService {
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
}
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
return glTxnId;
}
/**
* Records one clearing posting in the voucher register.
*
* Raised here rather than in each caller because CTS items, NACH movements and settlement
* summaries all post through this one path: a voucher per caller would be the same slip written
* three times, and one of them would drift.
*
* The caller's comment carries what the posting is — the item, the movement, the batch — so it
* becomes the narration on both sides. Where the caller named a customer account, the debit line
* carries that customer and account number; a settlement summary names no account and its lines
* stand on the GL names alone, which is what a summary is.
*
* A failure here does not fail the posting. The entry is already in the ledger, and clearing runs
* over batches where one unwritable slip must not take a whole settlement down with it. The
* problem is logged and the posting stands.
*/
private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
final String comments, final Long linkAccountId, final String linkAccountType) {
if (glTxnId == null) {
return;
}
try {
final Map<String, Object> debitGl = this.voucherService.glAccount(debitGlId);
final Map<String, Object> creditGl = this.voucherService.glAccount(creditGlId);
final Map<String, Object> holder = accountHolderForVoucher(linkAccountId, linkAccountType);
// The debit is the side a customer account is named against, matching the row this
// posting links to the account for reporting.
final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name");
final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no")
: (String) debitGl.get("gl_code");
this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId,
this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL,
List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments),
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
.withDescription(comments)));
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e);
}
}
/**
* The customer and account number a clearing item belongs to, for printing on a voucher.
*
* The type decides which table to read: savings and current accounts number independently, so
* the same id exists in both and looking in the wrong one would put another customer's name on
* the slip. Empty when the caller named no account, as a settlement summary does, or named one
* without saying which kind — a wrong name is worse on a voucher than no name.
*/
private Map<String, Object> accountHolderForVoucher(final Long accountId, final String accountType) {
if (accountId == null || accountType == null) {
return Map.of();
}
final String sql;
if ("SAVINGS".equalsIgnoreCase(accountType)) {
sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?";
} else if ("CURRENT".equalsIgnoreCase(accountType)) {
sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
} else {
return Map.of();
}
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
@@ -46,6 +46,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -65,6 +66,8 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -80,6 +83,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
public class ClerkDisbursementRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -109,7 +113,7 @@ public class ClerkDisbursementRequestApiResource {
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
if (requestType == null || requestType.isBlank()
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) {
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
requestType = "DISBURSEMENT";
}
@@ -141,6 +145,14 @@ public class ClerkDisbursementRequestApiResource {
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
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)) {
totalDr = totalDr.add(entryAmt);
} else {
@@ -185,9 +197,27 @@ public class ClerkDisbursementRequestApiResource {
activityLog, Timestamp.from(Instant.now()), requestType
);
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
// money out of the source side, so it reads as a debit — but only when the TXN TP. the
// clerk actually picked is Cash. That dropdown is the real-world signal for whether notes
// 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(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, txnAmount, currentUser);
voucherTypeLabel, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, txnAmount, currentUser, voucherPrefix);
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
@@ -544,7 +574,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId);
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -715,6 +745,11 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
}
// The sides of the slip, built from the entries as approved. Only reached once every
// posting above has succeeded, so the voucher describes a completed movement rather than
// one that was attempted.
addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType);
final String txnRefsStr = String.join(",", txnRefs);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
@@ -981,7 +1016,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, logKh);
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
final String voucherNo = generateBtVoucherNo(logId);
final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
final JsonObject res = new JsonObject();
@@ -997,9 +1032,28 @@ public class ClerkDisbursementRequestApiResource {
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
* under concurrent inserts on the same date.
*/
private String generateBtVoucherNo(final long logId) {
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%05d", logId);
/**
* Bank-transfer settlement voucher, drawn from the same central register as every other
* voucher so the two numbering schemes cannot drift apart.
*
* 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) {
return voucherService.createVoucher(
"Bank Transfer", VoucherService.REF_BANK_TRANSFER_LOG,
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 ───────────────────────────────────────────────────────────────────
@@ -1032,6 +1086,97 @@ public class ClerkDisbursementRequestApiResource {
);
}
/**
* Records the sides of an approved disbursement, collection or bank transfer on its voucher.
*
* The register covers three directions under one request type, and each puts the society's GL
* on a different side:
*
* <ul>
* <li>DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each
* account credited.</li>
* <li>COLLECTION — money into the target GL out of the listed accounts: each account debited,
* GL credited.</li>
* <li>BANK_TRANSFER — account to account with no GL involved at all; each entry already carries
* its own side, so they are used as given.</li>
* </ul>
*
* The society's side is written as a single line for the total rather than one per counterparty,
* because that is how it posts and how the slip reads.
*
* A failure here does not fail the approval: every transaction is posted by this point, and the
* money has moved. The problem is logged, and the voucher keeps its number without sides.
*/
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType);
final boolean isCollection = "COLLECTION".equals(requestType);
final List<VoucherLine> lines = new ArrayList<>();
BigDecimal counterpartyTotal = BigDecimal.ZERO;
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
? entry.get("accountName").getAsString()
: accountNo;
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? entry.get("glAccountId").getAsLong()
: null;
final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
? entry.get("savingsAccountId").getAsLong()
: null;
// A bank transfer states each side on the entry itself; the other two directions
// put every counterparty on the side opposite the society's GL.
final boolean entryIsDebit = isBankTransfer
? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")
: isCollection;
final VoucherLine line;
if (isGlEntry) {
line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount)
: VoucherLine.creditGl(glId, accountName, accountNo, entryAmount);
} else {
line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount)
: VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount);
}
lines.add(line);
if (!isBankTransfer) {
counterpartyTotal = counterpartyTotal.add(entryAmount);
}
}
// The society's own side. A bank transfer has none — the money never leaves the members
// between them — so its entries already balance on their own.
if (!isBankTransfer) {
final String glName = (String) record.get("gl_account_name");
final String glNo = (String) record.get("gl_account_no");
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
}
// Debits first, so the voucher prints the way a slip is read.
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
voucherService.addLines(voucherNo, lines);
} catch (final RuntimeException e) {
log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e);
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
@@ -1142,7 +1287,13 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
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;
}
@@ -1216,7 +1367,8 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
commandsSourceWritePlatformService.logCommandSource(cmd);
// The disbursement register raises its own voucher; suppress the journal command's own.
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
}
// ── Combined GL entry helpers ─────────────────────────────────────────────
@@ -1653,7 +1805,10 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
// The disbursement register raises its own voucher, naming every account involved; the
// journal command must not raise a second one over the same money.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
return result != null ? result.getTransactionId() : null;
}
@@ -41,12 +41,15 @@ import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -54,7 +57,12 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -74,10 +82,13 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class ClerkShareWithdrawalRequestApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
// 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
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
@@ -91,7 +102,25 @@ public class ClerkShareWithdrawalRequestApiResource {
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
// "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
// the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
// pre-settlement_payment_type_id-column legacy request still resolves to a real payment type
// instead of silently posting its settlement with none at all.
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
// ── Sundry pass-through for the redemption settlement ───────────────────
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
private static final int DEBIT = 2;
// acc_product_mapping.financial_account_type for share products.
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int PRODUCT_TYPE_SHARES = 4;
// acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control.
private static final int PRODUCT_TYPE_SAVINGS = 2;
private static final int FA_SAVINGS_CONTROL = 2;
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
@@ -187,8 +216,9 @@ public class ClerkShareWithdrawalRequestApiResource {
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
}
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
@@ -206,11 +236,11 @@ public class ClerkShareWithdrawalRequestApiResource {
where.append(" AND status = ?");
params.add(status);
}
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
return queryList(where.toString(), params, fromDate, toDate, null, offset, limit);
}
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
final int offset, final int limit) {
final String search, final int offset, final int limit) {
final StringBuilder where = new StringBuilder(baseWhere);
final List<Object> params = new ArrayList<>(baseParams);
if (fromDate != null && !fromDate.isBlank()) {
@@ -221,6 +251,13 @@ public class ClerkShareWithdrawalRequestApiResource {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
}
if (search != null && !search.isBlank()) {
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR resolution_number ILIKE ?)");
final String like = "%" + search.trim() + "%";
params.add(like);
params.add(like);
params.add(like);
}
final long total = Optional
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
@@ -320,7 +357,7 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied,
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null);
@@ -336,6 +373,14 @@ public class ClerkShareWithdrawalRequestApiResource {
: -1;
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
// Active only means approved and activated -- not paid for. Real share capital is only posted
// once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and
// approve() below hard-blocks on this same flag, so a request failing this check can never
// actually be approved regardless of what a checker decides here.
final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied"));
checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied,
collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet"));
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
: null;
@@ -585,6 +630,20 @@ public class ClerkShareWithdrawalRequestApiResource {
final Object meetingDateObj = record.get("meeting_date");
final String resolutionNumber = (String) record.get("resolution_number");
// Account creation, approval and activation post no share-capital GL of their own -- a share
// account can be Active with "approved" shares that nobody has actually paid for yet, and only
// gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see
// ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was
// never issued, so this is a hard block here, not just an advisory line in validate()'s
// checklist -- a checker approving off that checklist alone has no other backstop.
final Boolean collectionApplied = jdbcTemplate
.queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream()
.findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null);
if (!Boolean.TRUE.equals(collectionApplied)) {
throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been "
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
}
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
@@ -603,11 +662,20 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
// Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
// Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
// leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
// "give cash back over the counter" assumption breaks that the moment the settlement below
// lands on a different GL than this leg did.
if (shareTransactionId != null) {
routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
currentUser.getId());
}
// Settlement: within this SAME approval transaction, immediately credit the redemption value
// to the member's linked savings account. Two Fineract journal entries get posted — native
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
// to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
// not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
// land on the same account the leg above credited. See creditLinkedSavingsAccount().
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
? ((Number) storedSettlementPaymentTypeId).longValue()
@@ -641,9 +709,17 @@ public class ClerkShareWithdrawalRequestApiResource {
// shares left for the native close command to force-redeem without oversight.
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();
res.addProperty("resourceId", id);
res.addProperty("status", "APPROVED");
if (voucherNo != null) {
res.addProperty("voucherNo", voucherNo);
}
if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId);
}
@@ -682,6 +758,62 @@ public class ClerkShareWithdrawalRequestApiResource {
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) {}
/**
@@ -689,14 +821,17 @@ public class ClerkShareWithdrawalRequestApiResource {
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
* the unit price ourselves — the transaction was already priced by Fineract's own
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
* command so the transaction is fully audited and posts its own GL entry the normal way — the
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
* on that bridge account. Falls back to the legacy hardcoded
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
* column existed.
* today) and already nets out any redemption charge.
*
* <p>
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
* (Dr <i>whichever GL the settlement payment type's Fund Source maps to</i> / Cr Savings Control) is then
* <b>reversed in full</b> and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
* to be left to the payment type's own mapping in the hope it would land on the same account
* {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
* did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
* met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final String resolutionNumber, final Long settlementPaymentTypeId) {
@@ -735,7 +870,35 @@ public class ClerkShareWithdrawalRequestApiResource {
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return new Settlement(settlementAmount, result.getResourceId());
final Long savingsTransactionId = result.getResourceId();
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
final long userId = context.authenticatedUser().getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHRDMSTL-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no");
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settled to savings | " + accountNo);
insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settlement credited to savings | " + accountNo);
return new Settlement(settlementAmount, savingsTransactionId);
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@@ -806,6 +969,126 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
// ── Sundry pass-through helpers ──────────────────────────────────────────
/**
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
*
* <p>
* Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
* Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
* member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
* posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
* for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
* GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
* left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
* the same way every other share-capital movement in this codebase does.
*/
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
final long userId) {
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final String accountNo = (String) acct.get("account_no");
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Map<String, Object> reversedLeg = jdbcTemplate
.queryForList(
"SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
+ " AND type_enum = ? AND reversed = false",
shareTransactionId, glReference, CREDIT)
.stream().findFirst().orElse(null);
if (reversedLeg == null) {
// Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
// unexpected accounting configuration), so there is no leg here to reroute.
return;
}
final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
// Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
// grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
// Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
// instead of the two reading as the one balanced entry they actually are.
final String nativeTxnId = (String) reversedLeg.get("transaction_id");
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
+ " AND type_enum = ? AND reversed = false",
shareTransactionId, glReference, CREDIT);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption held in Sundry pending settlement | " + accountNo);
}
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
return jdbcTemplate.queryForMap("""
SELECT sa.account_no, sa.product_id, sa.currency_code, c.office_id
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id
WHERE sa.id = ?
""", shareAccountId);
}
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw apiError(409, label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (final IncorrectResultSizeDataAccessException e) {
return null;
}
}
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back
* as 00:04 the next day. The instant has to be resolved in the tenant's own zone first, which is the zone
* {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC);
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
JsonArray log = new JsonArray();
if (existingJson != null && !existingJson.isBlank()) {
@@ -54,7 +54,12 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.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.VoucherService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -69,7 +74,12 @@ import com.google.common.base.Splitter;
@RequiredArgsConstructor
public class ClerkWithdrawalRequestApiResource {
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
// activity JsonArray, and a field of the same name reads as that variable at a glance.
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -114,6 +124,7 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -151,7 +162,8 @@ public class ClerkWithdrawalRequestApiResource {
@QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
@QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
@@ -163,6 +175,14 @@ public class ClerkWithdrawalRequestApiResource {
where.append(" AND DATE(submitted_at) <= ?");
baseParams.add(Date.valueOf(toDate));
}
if (search != null && !search.isBlank()) {
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR voucher_no ILIKE ? OR tok_no ILIKE ?)");
final String like = "%" + search.trim() + "%";
baseParams.add(like);
baseParams.add(like);
baseParams.add(like);
baseParams.add(like);
}
final long total = Optional.ofNullable(
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
@@ -437,6 +457,11 @@ public class ClerkWithdrawalRequestApiResource {
final Long savingsAccountId = record.get("savings_account_id") != 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 Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
@@ -457,6 +482,10 @@ public class ClerkWithdrawalRequestApiResource {
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
}
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
addWithdrawalVoucherLines(record, savingsAccountId, amount);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
);
@@ -594,6 +623,11 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
}
// The voucher was raised in the clerk's name at submission, before any cashier was involved --
// reattribute it to whoever is actually handing over the cash now, so it shows up in their own
// voucher register rather than only in the clerk's.
voucherService.updateCashier(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, currentUser);
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
if (denominationData != null && !denominationData.isBlank()) {
saveDisbursementDenomination(denominationData, record, currentUser);
@@ -663,6 +697,7 @@ public class ClerkWithdrawalRequestApiResource {
final String accountNo = (String) record.get("account_no");
final Object clientIdObj = record.get("client_id");
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
final String paymentTypeName = (String) record.get("payment_type_name");
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
final int count;
@@ -686,16 +721,42 @@ public class ClerkWithdrawalRequestApiResource {
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashier.getId(), cashier.getUsername()
cashier.getId(), cashier.getUsername(), paymentTypeName
);
}
}
/**
* Records the two sides of an approved withdrawal on its voucher.
*
* Money leaves the member and is paid out over the counter, so the member's account is debited
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
*
* A failure here does not fail the approval: the withdrawal is already posted and the member
* already paid. The problem is logged, and the voucher keeps its number without sides.
*/
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
return;
}
try {
voucherService.addLines(voucherNo,
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
(String) record.get("account_no"), amount),
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -736,7 +797,13 @@ public class ClerkWithdrawalRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The withdrawal register raised its own voucher at submission and attaches the member and
// till sides to it on approval, so the savings command must not raise a second one over the
// same money -- one withdrawal was appearing twice on the cashier's payment report, once
// under the register's number and once under the savings transaction's, doubling the day's
// cash paid out.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -764,7 +831,11 @@ public class ClerkWithdrawalRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// Suppressed for the same reason as the savings path above. Current accounts raise no
// voucher of their own today, so this changes nothing now; it is here so that the day one
// is added, this register does not silently start double-counting again.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -798,9 +869,9 @@ public class ClerkWithdrawalRequestApiResource {
/**
* Ensures the token number is unique for today's date among non-rejected requests, checking
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
* {@code excludeId} is set during resubmit to skip the record being edited.
* this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} --
* all three are redeemed at the very same Transaction Entry token field, so none of the pools
* may ever collide. {@code excludeId} is set during resubmit to skip the record being edited.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder(
@@ -815,13 +886,19 @@ public class ClerkWithdrawalRequestApiResource {
final long ownCount = Optional.ofNullable(
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
).orElse(0L);
final long otherCount = Optional.ofNullable(
final long dividendCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + otherCount > 0) {
final long closureCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_clerk_closure_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + dividendCount + closureCount > 0) {
throw apiError(400,
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
}
@@ -0,0 +1,404 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredentialService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
/**
* A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share
* accounts are out of scope for this endpoint, see below), looked up by client number OR mobile number
* (the path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no})
* rather than the internal client id -- these are what a counter clerk has on the slip, or what a
* third-party caller was given, respectively, so no separate client-search step is needed before this
* call. Built so the whole thing, including the client lookup itself, resolves in a small handful of
* index-scan-only queries.
*
* Deliberately narrow on purpose:
* <ul>
* <li>Only ACTIVE accounts (status_enum 300) are returned -- a closed, pending, or rejected account is
* not part of "what does this client currently hold", which is the question this endpoint answers.</li>
* <li>No balance/currentValue/onHoldFunds figures at all, for any account type -- this is an account
* listing, not a balance enquiry; a caller that needs the money figure is expected to hit the
* account-specific balance endpoint for it (kept as a smaller, separate, cacheable concern rather than
* mixed into every listing response).</li>
* <li>Share accounts are excluded entirely -- they carry no balance in the deposit sense (approved
* shares x unit price is a valuation, not a balance) and don't belong in a "deposit accounts" listing.</li>
* </ul>
*
* Branch details (name, code, address, pincode, IFSC, MICR -- see {@code m_office_profile}, migrations
* 0511-0513/0538) and bank details (name, SWIFT/BIC, registered address, contact -- see
* {@code m_institution_profile}) are included on every response: a caller resolving "which branch/bank
* is this client's account actually at" (for a passbook header, a cheque book request, a KYC document)
* would otherwise need two more round trips, to {@code /v1/branchprofile/{officeId}} and
* {@code /v1/institutionprofile}.
*
* Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData}
* (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to
* m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
* caller doesn't need. Fewer joins and fewer columns is what actually makes this fast, not a different
* query engine.
*
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as
* they do every other endpoint; a third-party integration that should never see a Fineract username or
* password sends an {@code X-Api-Key} header instead (see {@link ApiClientCredentialService}), minted
* via {@code POST /v1/apiclients} by a super user. This path is carved out as {@code permitAll()} in
* {@code SecurityConfig} for exactly that second case -- the same posture this fork already uses for the
* IMPS and NPCI receivers, where Spring Security's outer gate stays open for one narrow path and the
* handler itself is what actually authenticates the caller.
*/
@Path("/v1/clientaccounts")
@Component
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of a client's ACTIVE Savings/Fixed Deposit/Recurring Deposit/Current accounts (no balances, no Share accounts) plus their branch details, by client number or mobile number.")
@RequiredArgsConstructor
public class ClientAllAccountsApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ApiClientCredentialService apiClientCredentialService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// Same header-precedence list CallerIpTrackingFilter uses elsewhere in this fork, kept local
// rather than depending on that filter bean: it's only conditionally registered
// (fineract.ip-tracking.enabled), and an API-key IP allowlist has to work regardless of whether
// that unrelated feature toggle is on.
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
// ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses
// the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the
// same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD
// (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table --
// current accounts were never folded into m_savings_account here). Every leg filters on
// client_id, which is indexed on both tables (fksa00000000000001, idx_ca_client_id) -- an
// index-only lookup per leg, not a scan.
private static final String ACCOUNTS_SQL = """
SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id,
sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
p.name AS product_name, p.short_name AS short_product_name,
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
FROM m_savings_account sa
JOIN m_savings_product p ON p.id = sa.product_id
WHERE sa.client_id = ? AND sa.status_enum = 300
UNION ALL
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id,
ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
ca.submittedon_date, ca.activatedon_date
FROM m_current_account ca
JOIN m_current_product cp ON cp.id = ca.product_id
WHERE ca.client_id = ? AND ca.status_enum = 300
""";
// Branch details for the client's own office -- name, code, address, pincode, IFSC, MICR -- so a
// caller doesn't need a second round trip to /v1/branchprofile/{officeId} just to print a passbook
// header or route a cheque book request. LEFT JOIN: an office with no m_office_profile row yet
// (see BranchProfileApiResource) is a valid, ordinary state, not an error -- the profile fields
// just come back null.
private static final String BRANCH_SQL = """
SELECT o.id AS office_id, o.name AS office_name, p.branch_code, p.branch_type, p.address,
p.pincode, p.ifsc_code, p.micr_code, p.contact_phone, p.contact_email
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
WHERE o.id = ?
""";
// The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row,
// id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a
// payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's
// IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative
// block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and
// the created/updated audit columns, which are an internal concern of the Bank Profile screen, not
// this endpoint's caller.
private static final String BANK_SQL = """
SELECT bank_name, legal_name, short_name, bank_code, swift_bic,
address_line1, address_line2, city, district, state, pincode,
contact_phone, contact_email, customer_care_number, website
FROM m_institution_profile WHERE id = 1
""";
@GET
@Path("/{identifier}")
@Produces(MediaType.APPLICATION_JSON)
public String retrieveByIdentifier(@PathParam("identifier") final String identifier,
@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
// Dual-mode: a header present means a third-party caller, checked against m_api_client and
// never touching Fineract's own login. No header means an ordinary logged-in user -- exactly
// the same authenticatedUser() gate every other resource in this codebase uses. The two are
// mutually exclusive on purpose: a request carrying an API key is never also allowed to fall
// back to "well, maybe they're logged in too" -- that would let a stolen key ride along on a
// legitimate session's cookies/headers.
if (StringUtils.isNotBlank(apiKey)) {
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
} else {
context.authenticatedUser();
}
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
final Map<String, Object> client = resolveClient(identifier, tenant);
final Long clientId = ((Number) client.get("id")).longValue();
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId);
final Map<String, Integer> countByType = new LinkedHashMap<>();
for (final Map<String, Object> row : rows) {
final String accountType = (String) row.get("accountType");
countByType.merge(accountType, 1, Integer::sum);
}
final Long officeId = ((Number) client.get("office_id")).longValue();
final Map<String, Object> branch = resolveBranch(officeId);
final Map<String, Object> bank = resolveBank();
final JsonObject res = new JsonObject();
// Which bank this response came from -- not decorative. One API key now authenticates
// against every tenant (see ApiClientCredentialService), so the ONLY thing that decides
// whose data comes back is the Fineract-Platform-TenantId header the caller sent. Echoing it
// (plus the bank's display name) back in the body means a caller debugging "why did I get
// client X's data instead of client Y's" can tell from the response alone, without having to
// go back and re-check what header value they actually sent.
res.addProperty("tenantId", tenant.getTenantIdentifier());
res.addProperty("tenantName", tenant.getName());
res.addProperty("searchedIdentifier", identifier);
res.addProperty("clientId", clientId);
res.addProperty("matchedBy", (String) client.get("matched_by"));
res.addProperty("clientNo", (String) client.get("account_no"));
res.addProperty("clientName", (String) client.get("display_name"));
res.addProperty("mobileNo", (String) client.get("mobile_no"));
res.addProperty("officeId", officeId);
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue());
res.add("branch", gson.toJsonTree(branch));
res.add("bank", gson.toJsonTree(bank));
res.addProperty("totalAccounts", rows.size());
res.add("accountsByType", gson.toJsonTree(countByType));
res.add("accounts", gson.toJsonTree(rows));
return gson.toJson(res);
}
/**
* An office with no {@code m_office_profile} row yet (see
* {@code org.apache.fineract.portfolio.orgsetup.api.BranchProfileApiResource}) is a valid, ordinary
* state -- every field beyond {@code officeId}/{@code officeName} just comes back null rather than
* this call failing or being skipped.
*/
private Map<String, Object> resolveBranch(final Long officeId) {
final Map<String, Object> row = jdbcTemplate.queryForMap(BRANCH_SQL, officeId);
final Map<String, Object> branch = new LinkedHashMap<>();
branch.put("officeId", officeId);
branch.put("officeName", row.get("office_name"));
branch.put("branchCode", row.get("branch_code"));
branch.put("branchType", row.get("branch_type"));
branch.put("address", row.get("address"));
branch.put("pincode", row.get("pincode"));
branch.put("ifscCode", row.get("ifsc_code"));
branch.put("micrCode", row.get("micr_code"));
branch.put("contactPhone", row.get("contact_phone"));
branch.put("contactEmail", row.get("contact_email"));
return branch;
}
/**
* The bank running this tenant is a singleton row (id=1) that only gets created the first time
* someone opens the Bank Profile screen (see {@code InstitutionProfileApiResource#ensureRowExists}
* -- this endpoint never creates it itself, it only reads). A tenant where nobody has ever opened
* that screen yet is a valid, ordinary state, not an error -- {@code bank} just comes back {@code
* null} in the response rather than this call failing.
*/
private Map<String, Object> resolveBank() {
final Map<String, Object> row;
try {
row = jdbcTemplate.queryForMap(BANK_SQL);
} catch (final EmptyResultDataAccessException e) {
return null;
}
final Map<String, Object> bank = new LinkedHashMap<>();
bank.put("bankName", row.get("bank_name"));
bank.put("legalName", row.get("legal_name"));
bank.put("shortName", row.get("short_name"));
bank.put("bankCode", row.get("bank_code"));
bank.put("swiftBic", row.get("swift_bic"));
bank.put("addressLine1", row.get("address_line1"));
bank.put("addressLine2", row.get("address_line2"));
bank.put("city", row.get("city"));
bank.put("district", row.get("district"));
bank.put("state", row.get("state"));
bank.put("pincode", row.get("pincode"));
bank.put("contactPhone", row.get("contact_phone"));
bank.put("contactEmail", row.get("contact_email"));
bank.put("customerCareNumber", row.get("customer_care_number"));
bank.put("website", row.get("website"));
return bank;
}
/**
* Resolves the path identifier as a client number first, then a mobile number -- two lookups in
* the worst case, one in the common case, both on unique indexed columns
* ({@code m_client_account_no_key}, {@code m_client_mobile_no_key} / {@code idx_client_mobile_no}),
* so neither costs more than the single index-scan lookup this whole endpoint is built around.
* Client number is tried first because it's the identifier a teller normally has on the slip in
* front of them; falling through to mobile number covers the third-party case where that's the
* only thing the caller was given. A value that happens to collide across both columns for two
* different clients is not handled specially -- account numbers and mobile numbers use disjoint
* formats in this system, so this cannot occur in practice.
*
* {@code tenant} is only for the not-found message: with one API key now valid across every bank
* (see {@link ApiClientCredentialService}), a lookup that finds nothing is as often "right
* identifier, wrong bank" (a stale or mistyped {@code Fineract-Platform-TenantId} header) as it is
* a genuinely unknown client -- naming the bank actually searched turns that from a guessing game
* into something the caller can act on immediately.
*/
private Map<String, Object> resolveClient(final String identifier, final FineractPlatformTenant tenant) {
final List<Map<String, Object>> byAccountNo = jdbcTemplate.queryForList("""
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
FROM m_client WHERE account_no = ?
""", identifier);
if (!byAccountNo.isEmpty()) {
final Map<String, Object> client = new LinkedHashMap<>(byAccountNo.get(0));
client.put("matched_by", "CLIENT_NO");
return client;
}
final List<Map<String, Object>> byMobileNo = jdbcTemplate.queryForList("""
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
FROM m_client WHERE mobile_no = ?
""", identifier);
if (!byMobileNo.isEmpty()) {
final Map<String, Object> client = new LinkedHashMap<>(byMobileNo.get(0));
client.put("matched_by", "MOBILE_NO");
return client;
}
throw apiError(404, "No client found with client number or mobile number '" + identifier + "' in tenant '"
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
}
/**
* Builds the error response body itself rather than throwing a bare {@code jakarta.ws.rs.*}
* exception: {@code NotFoundException(String)} only sets the Java exception's own message (for
* logs), it never puts that text on the wire -- no exception mapper in this app converts a plain
* NotFoundException's message into the response body, so the caller always got
* {@code {timestamp,status,error,path}} with nothing else, no matter how descriptive the message
* passed to the constructor was.
*
* One message field, not the {@code message}/{@code defaultUserMessage}/{@code errors[0]
* .defaultUserMessage} triple {@code ClerkClosureRequestApiResource#apiError} uses: that shape
* exists there to line up with Fineract's own multi-field validation errors, where {@code errors}
* really does carry one entry per invalid parameter. This endpoint never raises more than one
* error at a time, so the same sentence repeated three times said nothing extra, only made the
* body longer.
*/
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("httpStatusCode", httpStatus);
body.addProperty("message", message);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
private String resolveClientIp(final HttpServletRequest request) {
for (final String header : IP_HEADER_CANDIDATES) {
final String value = request.getHeader(header);
if (StringUtils.isNotBlank(value)) {
// X-Forwarded-For can carry a chain ("client, proxy1, proxy2") -- the first entry is
// the original caller, which is what an allowlist entry is written against.
return value.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
/**
* Maps one UNION leg row into a flat, display-ready shape -- category label and human status only.
* No balance figure of any kind: this is an account listing, not a balance enquiry (see the class
* Javadoc).
*/
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
@Override
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final int depositType = rs.getInt("deposit_type");
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("accountType", accountTypeFor(depositType));
row.put("accountId", rs.getLong("account_id"));
row.put("accountNo", rs.getString("account_no"));
row.put("externalId", rs.getString("external_id"));
row.put("productId", rs.getLong("product_id"));
row.put("productName", rs.getString("product_name"));
row.put("shortProductName", rs.getString("short_product_name"));
row.put("currencyCode", rs.getString("currency_code"));
// .toString(), not the raw LocalDate: this Gson instance has no java.time adapter
// registered, so it falls back to reflecting LocalDate's private fields -- which the JVM
// module system blocks outright (java.base does not open java.time to reflection from an
// unnamed module), turning every response into a 500. A plain ISO-8601 string needs no
// adapter and is what every other date field in this API already returns.
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
row.put("status", status);
return row;
}
private String accountTypeFor(final int depositType) {
return switch (depositType) {
case 100 -> "Savings Account";
case 200 -> "Fixed Deposit";
case 300 -> "Recurring Deposit";
case 400 -> "Current Account";
default -> "Other";
};
}
private String toIsoDateString(final LocalDate date) {
return date == null ? null : date.toString();
}
}
}
@@ -160,10 +160,13 @@ public class ClientKycApiResource {
template.put("clientTypeName", clientTypeName);
template.put("legalFormId", legalFormId);
// Minor detection mirrors the frontend's own rule (age < 18) so the document list
// served here stays consistent with what the UI computes from the same date of birth.
// Minor detection mirrors the frontend's own rule so the document list served here stays
// consistent with what the UI computes from the same date of birth. Both sides read the
// threshold from a single named constant -- when they disagree, this endpoint wins and would
// silently serve an adult document list (mandatory PAN) to a minor.
final LocalDate dateOfBirth = client.dateOfBirth();
final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18;
final boolean isMinor = dateOfBirth != null
&& Period.between(dateOfBirth, LocalDate.now()).getYears() < ClientApiConstants.MINOR_AGE_THRESHOLD;
template.put("isMinor", isMinor);
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor));
@@ -70,4 +70,23 @@ public class ClientKycExceptionsApiResource {
}
return this.toApiJsonSerializer.serialize(exceptions);
}
/**
* Customers whose periodic re-KYC has already fallen due.
*
* <p>
* Sits under the exceptions path because it is the same worklist to the branch — a customer the
* bank has to reach — and the outreach is recorded against the same contact log.
*/
@GET
@Path("lapsed")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve lapsed KYC records", description = "Returns clients whose periodic re-KYC due date has passed, oldest first.")
public String retrieveLapsedKyc() {
this.context.authenticatedUser();
final List<ClientKycData> lapsed = this.readPlatformService.retrieveLapsedKyc();
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
return this.toApiJsonSerializer.serialize(lapsed);
}
}
@@ -27,6 +27,10 @@ public final class KycRiskRuleData implements Serializable {
private final Long id;
private final 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 Long occupationCategoryCvId;
private final String occupationCategoryName;
@@ -61,7 +65,7 @@ public final class KycRiskRuleData implements Serializable {
private final BigDecimal txnMinLimitAmount;
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 occupationCvId, final String occupationName,
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
@@ -75,6 +79,7 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id;
this.ruleName = ruleName;
this.ruleKind = ruleKind;
this.legalFormId = legalFormId;
this.occupationCategoryCvId = occupationCategoryCvId;
this.occupationCategoryName = occupationCategoryName;
@@ -111,6 +116,10 @@ public final class KycRiskRuleData implements Serializable {
return ruleName;
}
public String getRuleKind() {
return ruleKind;
}
public Integer getLegalFormId() {
return legalFormId;
}
@@ -40,6 +40,12 @@ public class KycRiskRuleRequest {
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
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",
requiredMode = Schema.RequiredMode.REQUIRED)
private String reason;
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ApplyKycExpiryRestrictionsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step applyKycExpiryRestrictionsStep(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
return new StepBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
.tasklet(applyKycExpiryRestrictionsTasklet, transactionManager).build();
}
@Bean
public Job applyKycExpiryRestrictionsJob(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
return new JobBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
.start(applyKycExpiryRestrictionsStep(applyKycExpiryRestrictionsTasklet)).incrementer(new RunIdIncrementer()).build();
}
}

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