Compare commits

...
31 Commits
Author SHA1 Message Date
anshul 11a0d502be Enforce same-user maker-checker restriction
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 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 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 Docker Builds / build (true, docker-compose.yml, mariadb) (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 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 - 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-10-01 12:03:32 +05:30
anshul 2afd4aaf83 Enforce same-user maker-checker restriction 2026-09-30 17:07:16 +05:30
anshul e5dd6af366 Merge dev-latest into feature/bugfix 2026-09-30 16:47:41 +05:30
niteshandClaude Sonnet 5 9436eab129 feat(security): seed a fixed default platform API key via migration instead of a random one at runtime
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, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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 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
Replaces the previous DefaultApiClientBootstrapper (ApplicationRunner
minting a random key on first startup) with a plain Liquibase seed on the
tenant-store database: one fixed, well-known api_key_hash row, inserted
once via migration 0012, same on every deployment and every tenant --
exactly like this fork already seeds default codes/permissions, rather
than generating a secret at runtime.

The trade-off is explicit in the migration's own comment: a fixed key
checked into source is a bootstrap convenience, not a secret, and any
deployment beyond local/dev must rotate it (insert a new hash, deactivate
this row) before going live -- rotation is a direct m_api_client write,
same as the seed itself, since POST /v1/apiclients stays disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 17:01:07 +05:30
niteshandClaude Sonnet 5 1408bddf58 feat(security): auto-mint the first platform-operator API key on deploy, disable minting one over HTTP
A fresh deployment's m_api_client table starts empty, and reaching
#/platform-admin/menu-access requires a valid X-Api-Key -- with no key,
every tenant 401s identically (this is by design, not tenant-specific).

Add DefaultApiClientBootstrapper: an ApplicationRunner that mints exactly
one random key on first startup (guarded by "table still empty", so later
restarts are a no-op) and logs the raw value once to the server's own
application log -- never to the database, never over HTTP.

Disable ApiClientAdminApiResource's POST /v1/apiclients entirely: it only
required ALL_FUNCTIONS, a per-tenant role grant, so any bank's own super
user (or a misconfigured role, as "Clerk" was found to carry it in two
tenants this session) could mint themselves a platform-wide operator key
and bypass the exact ceiling TenantMenuAccessApiResource exists to enforce.
New keys past the first are now a direct m_api_client insert only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 15:36:18 +05:30
anshul e02ab51ee1 Merge branch 'dev-latest' into feature/bugfix
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract 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 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-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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 - 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
2026-09-29 10:21:21 +05:30
nitesh 3a37a5f90f Merge branch 'feature/bugfix' into dev-latest
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract 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-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-28 18:36:12 +05:30
niteshandClaude Sonnet 5 e7417d16a6 chore: renumber migration 0585 to 0587 ahead of feature/bugfix merge
feature/bugfix independently used 0585/0586 for unrelated migrations
(loan COB catch-up, system module checker permissions), colliding with
this session's own 0585 file. Renumbered to the first free slot after
theirs so both sets of changesets coexist without ambiguity; content and
semantics are unchanged, only the file name and changeset ids moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:35:11 +05:30
niteshandClaude Sonnet 5 24d3bf83bb fix: correct Current-product OD columns, PortfolioProductType mapping, and duplicate Bank Transfer voucher
Fineract Cargo & Unit- & Integration tests - MySQL / 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 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 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-3) (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-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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
- Add migration 0585 to add the OD-scheme columns to the correctly-named
  pending-edit shadow table (m_saving_product_temp, singular), fixing
  migration 2033's typo that made it a permanent no-op and broke editing
  any Current product with OD fields set.
- Fix PortfolioProductType.fromInt() to switch on each constant's declared
  value instead of declaration position, which silently mis-mapped ids 3-5
  and NPE'd on id 6 (CURRENT), crashing Journal Entries for Current-account
  GL rows.
- Stop generating a second Bank Transfer voucher at approve time; reuse the
  one already created at raise/submit time instead, eliminating duplicate
  vouchers for the same transfer under two different users' names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:53:25 +05:30
anshul af052dcb4f Merge branch 'dev-latest' into feature/bugfix
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-28 17:22:25 +05:30
keshav b12268e740 feat: add RBI Form A and Form 9 SLR filing APIs, jobs, and migrations
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-28 13:35:32 +05:30
anshul 6ee3bbe67c feat: add command handlers, permissions, and API resources for surveys and loan COB catch-up
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-28 12:55:21 +05:30
anshul d8f6c25875 Merge branch 'dev-latest' into feature/bugfix
especially if it merges an updated upstream into a topic branch.
2026-09-28 12:39:03 +05:30
niteshandClaude Sonnet 5 2155c94161 fix(fd): Reinvest Principal Only renews in place, same account, instead of opening a new one
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
Reinvest Principal Only was still on the legacy close-and-reopen path (open
a new FD with just the principal, close the old one) while Reinvest
Principal + Interest and Partial Reinvest had already moved to renewInPlace
-- the same customer choosing a different split ended up with two different
outcomes: same account number, or a brand new one. Unified all three onto
renewInPlace: Principal Only is now exactly Partial Reinvest with the split
fixed at the closing principal, so it renews on the same account with only
the leftover interest actually leaving (to the chosen/linked Savings
account), in both handleFDAccountPayment (Payment dialog / FD Dashboard
Renew) and handleFDAccountMaturityClosure (the nightly job's path for an
account with an already-stored instruction).

Also fixes a related bug found while testing this: recordInPlaceRenewal
re-archived the closing term's full rate chart on every renewal of the same
term, even when it had already been archived by an earlier renewal --
archiveChartSlabs now only runs the first time a term's closing row is
created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:41:18 +05:30
niteshandClaude Sonnet 5 87f3e591ce fix(accounting): tag current-account GL entries entity_type_enum=CURRENT instead of reusing SAVING, everywhere this codebase links an entry to its account
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 Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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
m_current_account and m_savings_account each keep their own identity
sequence, so the same numeric id can be a real row in both. Every place
that links a GL journal entry back to its account for display -- so the
Journal Entries screen shows an Account No instead of a blank "-" --
was tagging entity_type_enum=2 (PortfolioProductType.SAVING)
unconditionally, even when the account was actually a current account.
Whenever a current account's id collided with an unrelated savings
account's id, the entry resolved to the wrong one entirely: a customer's
savings deposit could display someone else's current account number, or
vice versa.

Fixed at the write side, in every place found doing this:
- ClerkDisbursementRequestApiResource (Bank Transfer, Collection,
  Disbursement) -- GlEntry now carries an isCurrentAccount flag, decided
  once where the account was already resolved, instead of being
  re-guessed.
- ChequeBookIssuanceChargeService, ClearingJournalService,
  SponsorDDIssueRequestWritePlatformServiceImpl -- same pattern, each
  already knew savings vs current at the point it linked the entry; that
  answer is now threaded through instead of discarded.

Also fixed on the read side:
- JournalEntryReadPlatformServiceImpl: entity_type_enum=6 now resolves
  unambiguously to the current account; entity_type_enum=2 still falls
  back to savings_transaction_id (a reliable signal only a genuine
  savings transaction sets) before guessing, for older rows written
  before this fix.
- JournalEntryReferralServiceImpl (AML referral resolution): the same
  ambiguity could attribute a suspicious-activity alert to the wrong
  client entirely -- a same-numbered stranger's account -- when a
  current account's entity_id collided with a savings account's.
  Resolved with the same entity_type=6 / savings_transaction_id
  precedence as the read-side fix.

Not touched: JournalEntryAggregationJobReader (year-end aggregation
batch job) has the same ambiguity for legacy entity_type_enum=2 rows,
but only ever joins m_savings_account -- it has no current-account
handling at all, standard-Fineract in origin. Extending it is a real
feature addition, not a targeted fix, and touches a scheduled financial
batch job this session cannot rebuild or run to verify; flagged for
separate, deliberate work instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 10:34:39 +05:30
niteshandClaude Sonnet 5 1e903232f0 fix(fd): pay late-renewal gap interest to the linked Savings account, not the FD
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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract 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 7 of 10) (push) Canceled after 0s
Gap-period interest is earned at the linked account's rate, not the FD's --
folding it onto the FD's own balance meant "Re-Invest Maturity Amount"
silently carried savings interest into the new FD term as if it were FD
interest, and "Re-Invest Principal Only" paid it out through the FD's own
closure math instead of it already being where it belongs. Now posted
directly onto the linked account as its own INTEREST_POSTING transaction;
the FD's balance is left untouched, so every closure type renews/transfers
its real maturity amount. Renewal Preview updated to match (projectedBalance
is now just the FD's own balance) and now also returns the linked account's
own account number, so staff can see exactly where the gap interest is
about to land before confirming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:11:50 +05:30
niteshandClaude Sonnet 5 2c49d6f637 feat(fd): add read-only renewal preview endpoint, fix stale linked-account reads
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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract 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
GET /fixeddepositaccounts/{id}/renewal-preview -- shown in the FD
Dashboard's Payment dialog before staff confirm a late renewal, so they see
the actual numbers (linked account's rate, gap days, gap-interest amount,
projected new balance) instead of confirming blind. Shares its calculation
with the posting path (creditGapInterest) via a new computeGapInterestAmount
helper, so the previewed number is exactly what gets credited.

Also fixes fdAccount.getLinkedAccountId() returning null for an account
confirmed to have linked_account_id set in the database -- every caller
that needs the FD's Deposit Account Link now reads it fresh via SQL
(resolveLinkedAccountId) instead of that stale JPA entity accessor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:55 +05:30
niteshandClaude Sonnet 5 331c553164 fix(accounting): interest postings were never reaching the general ledger
createInterestPostingJournalEntriesForSavings() documents a 3-stage
posting (Dr Interest On Savings -> Cr Payable, Dr Payable -> Cr Paid, Dr
Paid -> Cr Savings Control), but the first stage was never actually coded
-- the interest expense account was never even fetched. For a product with
no distinct Interest Payable/Paid accounts configured (every product in
this system, checked directly), the remaining two stages then collapsed
into a same-account no-op that correctly refuses to post, leaving interest
silently added to the account's balance with zero GL entries. Affects every
deposit type equally (Savings, FD, RD), not just FD -- confirmed by
checking every product's accounting mappings. Added the missing stage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:46 +05:30
niteshandClaude Sonnet 5 3fb008a22a fix(products): clearer error message for an FD product amount field that's too large
The FD product's exception mapper fell back to a bare "Unknown data
integrity issue with savings product." for any DB error it didn't
recognize, with no detail -- unlike its sibling class for regular Savings
products, which at least appends the underlying error. Added a specific,
actionable message for the numeric-overflow case (an amount field entered
too large for its column), and made the generic fallback include the real
detail too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:28 +05:30
niteshandClaude Sonnet 5 8b2fb052d4 fix(fd): keep a genuinely-matured FD on the Dashboard instead of dropping it
The maturity-dashboard query only ever selected ACTIVE(300) accounts. That
was fine while nothing ever really reached MATURED(800) in this dev setup
(test dates were only SQL-backdated, never run through the real nightly
job), but the first time the job actually flipped an account's real status,
it silently vanished from the ladder instead of showing under "Matured" --
exactly the state the ladder exists to surface. Now selects ACTIVE or
MATURED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:21 +05:30
anshul cebba87864 Merge branch 'dev-latest' into feature/bugfix
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 (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 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 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 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
2026-09-25 11:24:24 +05:30
niteshandClaude Sonnet 5 ea39c8e82a fix(current-account): recognize Withdrawn and Rejected as terminal when checking for a duplicate product application
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 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 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
countActiveByClientAndProduct only excluded status 600 (CLOSED), so a
client whose prior application for the same product was Withdrawn (400)
or Rejected (500) -- both terminal, per CurrentAccount.withdraw()/
reject() -- was still blocked from applying again, same as if that
account were genuinely still open. Only 100/200/300 (submitted,
approved, active) should collide with a fresh application.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:22:31 +05:30
niteshandClaude Sonnet 5 32411c924b fix(accounting): stop the Journal Entry view showing a colliding current account's number for a savings GL entry
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 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 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
entity_type_enum=2 (PortfolioProductType.SAVING) is written for both
savings and current-account journal entries -- current products
deliberately share SAVING's GL type space -- so it can't distinguish
them, and m_savings_account/m_current_account each have their own id
sequence, meaning an entity_id can be a real row in both. The account-
number column's COALESCE(ca.account_no, sa.account_no) always preferred
the current account's number whenever both matched, so a savings
deposit's own journal entry displayed an unrelated current account's
number instead of the account it actually posted against.

savings_transaction_id is the one reliable signal available on this
query: only a genuine savings transaction sets it. Checking it first
resolves the correct account whenever it's present, and falls back to
the previous behavior only when it's absent (current-account entries,
which don't set it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 10:58:44 +05:30
niteshandClaude Sonnet 5 f696499102 fix(current-account): stop account-id collisions between savings and current accounts from misrouting money and losing GL entries
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 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract 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 - 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
m_current_account and m_savings_account each keep their own identity
sequence, so the same numeric id can be a real row in both -- an id-only
lookup used to decide "is this a current account?" once sent a real
deposit into an unrelated client's current account instead of the
savings account a request actually named, across every entry point that
did this: cashier deposit approval/reprocess, clerk withdrawal approval,
and clerk disbursement collection/disbursement/bank-transfer. All of
those now also verify the account_no before trusting an id match.

Separately, GL posting for current-account transactions has been
silently failing since acc_product_mapping had two rows for the same
(product_id, product_type, financial_account_type) slot -- current
products share PortfolioProductType.SAVING's id space with real savings
products, and a NULL payment_type doesn't collide under that column's
own unique constraint, so duplicates went unnoticed. The single-result
repository lookup that read this mapping threw on the duplicate, which
was swallowed by a try/catch, so account balances kept updating while no
journal entry was ever posted. Both current-account GL lookups now take
the most recently created mapping instead of failing outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:14:32 +05:30
anshul 7eabaf2542 Merge branch 'dev-latest' into feature/bugfix
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-24 18:09:48 +05:30
keshav 4b9093007f feat: update TDS config, RBI reserve, current products, OSS returns, and dormancy services
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-24 18:06:29 +05:30
anshul 9beae3df28 Implement organization maker-checker workflows
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 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 Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-24 17:22:39 +05:30
niteshandClaude Sonnet 5 d2e13cc7b9 feat(fd): dashboard-driven maturity renewal, plus fix stranded interest on Reinvest Principal Only
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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-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 - 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 Messaging Smoke Tests / Smoke Test with ActiveMQ (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 Kafka (push) Canceled after 0s
A SINGLE/DOUBLE FD opened with an explicit maturity instruction still auto-processes on its own
via the nightly job, exactly as before -- the grace period never delays that. An FD opened with
NO instruction at all (the new default -- see the frontend's account-creation changes) is now
never touched by the job; it just sits matured until staff renew it manually from the FD
Dashboard, backdated to the original maturity date within the product's grace window, or from
today with gap-period interest (at the linked account's rate) credited first once it's passed.

New endpoints:
- POST /fixeddepositaccounts/{id}/renew -- the Dashboard's own quick Reinvest choice
  (Principal+Interest / Principal Only), for the two-option case.
- POST /fixeddepositaccounts/{id}/credit-gap-interest -- a pre-step the frontend's Payment
  dialog now calls before submitting the existing generic `payment` command, so the Dashboard's
  Renew action offers every closure type Payment does (Transfer to Savings, both Reinvest
  options, Partial Reinvest), not just two, while still crediting gap-period interest first.

Also fixes a real, pre-existing bug found while building this: REINVEST_PRINCIPAL_ONLY never
actually paid its leftover interest anywhere -- handleFDAccountMaturityClosure withdrew only the
principal into the new deposit and never referenced its own toSavingsId parameter, silently
stranding the interest on an account it also never closed. Now transfers the remainder to the
target savings account and actually closes the old account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:55:25 +05:30
niteshandClaude Sonnet 5 ecfe8aa76a feat(fd): add Renewal Grace Period product field, both storage paths
New per-FD-product "Renewal Grace Period (Days)" term field: null/0 (every existing product)
keeps today's instant, same-night auto-renewal; a positive value is later read by the nightly
maturity job and the manual Renew action to decide whether/how a matured FD waits for staff to
act instead of renewing itself.

Wired into the live product path end-to-end -- DepositsApiConstants param, DepositTermDetail
column + diffing, DepositProductAssembler (create and update), DepositProductReadPlatformServiceImpl,
FixedDepositProductData -- migration 0572 adds the column.

Also wired into the maker-checker pending-edit mirror (m_saving_product_temp /
FixedDepositEditFields / ProductPendingEditFieldMapper), a second, independent storage path a
governed product edit uses -- without this the field silently dropped out of a checker's
"Proposed changes" review even though it was submitted. Migration 0573 adds its column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:55:11 +05:30
niteshandClaude Sonnet 5 a7cb09f15c feat(fd): add lean FD maturity dashboard read endpoint
New GET /v1/fixeddepositaccounts/maturity-dashboard: a narrow, purpose-built query for the FD
Dashboard's maturity ladder (account/client/product identity, maturity date, deposit/maturity
amounts) instead of routing through the general FixedDepositAccountsApiResource#retrieveAll,
whose mapper joins six extra tables for audit/currency/tax fields this screen never shows.

Adds the index migration (0571) the dashboard's WHERE clause needs -- neither
m_savings_account.deposit_type_enum/status_enum nor
m_deposit_account_term_and_preclosure.maturity_date had one before, so the endpoint would
otherwise run a full table scan on top of being wide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:54:57 +05:30
niteshandClaude Sonnet 5 26d4c241c7 fix(clerk-disbursement): validate savings withdrawal eligibility when a request is raised
A clerk raising a disbursement request with a Dr leg against a savings account had no way to
know the withdrawal would be refused (inactive account, still locked-in, or insufficient
balance against its own minimum) until a manager tried to approve it and the whole request
bounced back. Checks the same conditions SavingsAccount.withdraw() enforces at entry time
instead, against the same columns, so the clerk sees the real reason immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:54:03 +05:30
157 changed files with 12937 additions and 3484 deletions
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return accountMappingDetails;
}
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
@Override
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
PortfolioProductType.CURRENT.getValue());
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
return setCashSavingsProductToGLAccountMaps(mappings);
}
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
}
return null;
}
@Override
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
// Interest Payable is editable on cash too, and optional. Interest posting routes
// through it on either basis, so a product can name one to carry interest declared
// but not yet credited; a product that names none still posts straight to Savings
// Control. Created-or-merged because a product configured before this row existed
// has no mapping to merge into.
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
break;
case ACCRUAL_PERIODIC:
// asset
@@ -137,7 +137,16 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
// isApprovedByChecker is only ever true here for a genuine checker approval
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
// auto-approve their own submission just by holding it.
if (isApprovedByChecker) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,25 +160,6 @@ 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;
@@ -756,6 +756,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder deleteFund(final Long fundId) {
this.actionName = "DELETE";
this.entityName = "FUND";
this.entityId = fundId;
this.href = "/funds/" + fundId;
return this;
}
public CommandWrapperBuilder createReport() {
this.actionName = "CREATE";
this.entityName = "REPORT";
@@ -884,6 +892,70 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createPaymentType() {
this.actionName = "CREATE";
this.entityName = "PAYMENTTYPE";
this.entityId = null;
this.href = "/paymenttypes/template";
return this;
}
public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) {
this.actionName = "UPDATE";
this.entityName = "PAYMENTTYPE";
this.entityId = paymentTypeId;
this.href = "/paymenttypes/" + paymentTypeId;
return this;
}
public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) {
this.actionName = "DELETE";
this.entityName = "PAYMENTTYPE";
this.entityId = paymentTypeId;
this.href = "/paymenttypes/" + paymentTypeId;
return this;
}
public CommandWrapperBuilder createSponsorBank() {
this.actionName = "CREATE";
this.entityName = "SPONSORBANK";
this.entityId = null;
this.href = "/sponsorbanks";
return this;
}
public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) {
this.actionName = "UPDATE";
this.entityName = "SPONSORBANK";
this.entityId = sponsorBankId;
this.href = "/sponsorbanks/" + sponsorBankId;
return this;
}
public CommandWrapperBuilder updateSponsorDdGlMapping() {
this.actionName = "UPDATE";
this.entityName = "SPONSORDDGLMAPPING";
this.entityId = null;
this.href = "/sponsordd/gl-mapping";
return this;
}
public CommandWrapperBuilder updateInstitutionProfile() {
this.actionName = "UPDATE";
this.entityName = "INSTITUTIONPROFILE";
this.entityId = null;
this.href = "/institutionprofile";
return this;
}
public CommandWrapperBuilder updateBranchProfile(final Long officeId) {
this.actionName = "UPDATE";
this.entityName = "BRANCHPROFILE";
this.entityId = officeId;
this.href = "/branchprofile/" + officeId;
return this;
}
public CommandWrapperBuilder createLoanProduct() {
this.actionName = "CREATE";
this.entityName = "LOANPRODUCT";
@@ -3390,6 +3462,13 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder updateCurrency() {
this.actionName = "UPDATE";
this.entityName = "CURRENCY";
this.href = "/currencies";
return this;
}
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
this.actionName = "UPDATE";
this.entityName = "EXTERNALSERVICES";
@@ -4730,4 +4809,36 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/originators/" + originatorId;
return this;
}
public CommandWrapperBuilder createSurvey() {
this.actionName = "CREATE";
this.entityName = "SURVEY";
this.entityId = null;
this.href = "/surveys";
return this;
}
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
this.actionName = "UPDATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
this.actionName = "ACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
this.actionName = "DEACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
}
@@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
private final FromJsonHelper fromApiJsonHelper;
private final CommandProcessingService processAndLogCommandService;
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
// live, independently-configurable global switch (System -> Configurations); deleting this field,
// and by extension deciding that switch should stop existing altogether, is a separate decision
// from closing this specific self-approval loophole.
private final ConfigurationDomainService configurationService;
@Override
@@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode);
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
}
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
}
// Maker-identity rule: the user who submitted a command may never be the one who approves
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
// permission still lets its holder action a DIFFERENT user's request without needing the
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
// longer lets them action their own) and not overridable by the enable-same-maker-checker
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
// configuration is left in place and unused for this decision rather than removed, since
// deleting a still-configurable global setting is a separate decision from this one).
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
}
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
}
return commandSource;
}
@@ -73,6 +73,7 @@ public enum JobName {
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
; //
private final String name;
@@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
import org.springframework.stereotype.Component;
@@ -44,7 +45,8 @@ import org.springframework.stereotype.Component;
public class CurrenciesApiResource {
private final OrganisationCurrencyReadPlatformService readPlatformService;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CurrencyUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,13 +67,12 @@ public class CurrenciesApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) {
final var command = new CurrencyUpdateCommand();
public String updateCurrencies(@Valid final CurrencyUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency()
.withJson(toApiJsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<CurrencyUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,63 @@
/**
* 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.monetary.handler;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CURRENCY", action = "UPDATE")
public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler {
private final CurrencyWritePlatformService writePlatformService;
@Autowired
public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies");
final List<String> currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes);
final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build();
final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(Map.of("currencies", response.getCurrencies())) //
.build();
}
}
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
SAVING(2, "productType.saving"), //
CLIENT(5, "productType.client"), //
PROVISIONING(3, "productType.provisioning"), //
SHARES(4, "productType.shares"); //
SHARES(4, "productType.shares"), //
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
// product and a current product with the same id read each other's GL rows.
CURRENT(6, "productType.current"); //
private final Integer value;
private final String code;
@@ -52,17 +55,25 @@ public enum PortfolioProductType {
return null;
}
// Matched against each constant's own declared value above, not its declaration position --
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
// (entity_type_enum=6) reached this on the Journal Entries screen.
switch (v) {
case 1:
return LOAN;
case 2:
return SAVING;
case 3:
return CLIENT;
case 4:
return PROVISIONING;
case 5:
case 4:
return SHARES;
case 5:
return CLIENT;
case 6:
return CURRENT;
default:
return null;
}
@@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.springframework.stereotype.Component;
@@ -55,7 +50,7 @@ import org.springframework.stereotype.Component;
public class PaymentTypeApiResource {
private final PaymentTypeReadService readPlatformService;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
@GET
@@ -79,14 +74,13 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) {
final var command = new PaymentTypeCreateCommand();
public String createPaymentType(final PaymentTypeCreateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType()
.withJson(jsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeCreateResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
@PUT
@@ -94,18 +88,16 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId,
final PaymentTypeUpdateRequest request) {
public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) {
request.setId(paymentTypeId);
final var command = new PaymentTypeUpdateCommand();
final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId)
.withJson(jsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeUpdateResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
@DELETE
@@ -113,14 +105,12 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
final var command = new PaymentTypeDeleteCommand();
final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build();
command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build());
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeDeleteResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
}
@@ -18,33 +18,50 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
return paymentTypeWriteService.createPaymentType(command.getPayload());
@Autowired
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder()
.name(command.stringValueOfParameterNamed("name"))
.description(command.stringValueOfParameterNamed("description"))
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
.position(command.longValueOfParameterNamed("position"))
.codeName(command.stringValueOfParameterNamed("codeName"))
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
.build();
final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -18,33 +18,41 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeDeleteCommandHandler implements CommandHandler<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "DELETE")
public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeDeleteResponse handle(Command<PaymentTypeDeleteRequest> command) {
return paymentTypeWriteService.deletePaymentType(command.getPayload());
@Autowired
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build();
final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -18,33 +18,51 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeUpdateCommandHandler implements CommandHandler<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "UPDATE")
public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeUpdateResponse handle(Command<PaymentTypeUpdateRequest> command) {
return paymentTypeWriteService.updatePaymentType(command.getPayload());
@Autowired
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder()
.id(command.entityId())
.name(command.stringValueOfParameterNamed("name"))
.description(command.stringValueOfParameterNamed("description"))
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
.position(command.longValueOfParameterNamed("position"))
.codeName(command.stringValueOfParameterNamed("codeName"))
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
.build();
final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -143,6 +143,9 @@ public final class DepositsApiConstants {
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
public static final String depositAmountParamName = "depositAmount";
public static final String depositMinAmountParamName = "minDepositAmount";
@@ -248,11 +251,13 @@ public final class DepositsApiConstants {
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
@@ -286,6 +286,15 @@ public class SavingsApiConstants {
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
public static final String validForPeriodParamName = "validForPeriod";
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
public static final String odMinLimitParamName = "odMinLimit";
public static final String odSecurityTypeParamName = "odSecurityType";
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
public static final String odExcessAllowedParamName = "odExcessAllowed";
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
public static final String odPenalRateParamName = "odPenalRate";
// Status of Account fields
public static final String accountStatusParamName = "accountStatus";
@@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private String avgBalanceCalcPeriod;
@Getter @Setter private String validForPeriod;
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
@Getter @Setter private BigDecimal odMinLimit;
@Getter @Setter private String odSecurityType;
@Getter @Setter private Boolean odExcessAllowed;
@Getter @Setter private BigDecimal odExcessLimitPercent;
@Getter @Setter private BigDecimal odPenalRate;
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
@Getter @Setter private Map<String, Object> accountFacilityConfig;
@@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable {
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
target.setValidForPeriod(source.getValidForPeriod());
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
target.setOdMinLimit(source.getOdMinLimit());
target.setOdSecurityType(source.getOdSecurityType());
target.setOdExcessAllowed(source.getOdExcessAllowed());
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
target.setOdPenalRate(source.getOdPenalRate());
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
@Column(name = "status_enum", nullable = false)
private Integer statusEnum = 100;
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
// 600=BlockDebit
@Column(name = "sub_status_enum", nullable = false)
private Integer subStatusEnum = 0;
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
// active) are the only statuses a second application on the same product should actually collide
// with.
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
}
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "office_id", nullable = false)
private Office office;
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
@Column(name = "transaction_type_enum", nullable = false)
private Integer transactionTypeEnum;
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
return txn;
}
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate) {
CurrentAccountTransaction txn = new CurrentAccountTransaction();
txn.currentAccount = account;
txn.office = office;
txn.transactionTypeEnum = 19;
txn.amount = amount;
txn.transactionDate = transactionDate;
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
txn.submittedOnDate = transactionDate;
return txn;
}
// transactionTypeEnum = 7 (Reversal / Undo)
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
/**
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
*/
boolean escheat(Long accountId);
}
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
SavingsApiConstants.glAccountIdParamName));
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
/** How long an OD sanction on this scheme runs before it has to be renewed. */
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
/** Excess drawing is a small tolerance over the limit, never a second limit. */
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, null);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
}
if (null != isDormancyActive && isDormancyActive) {
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
}
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, product);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
}
}
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
/**
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
* as RBI requires for advances, or at most quarterly.
*
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
* checked against the scheme it will produce; on create it is null.
*/
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
final CurrentProduct product) {
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
}
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
: product != null && product.isAllowOverdraft();
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
product == null ? null : product.getOverdraftLimit());
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
product == null ? null : product.getMinOverdraftForInterestCalculation());
if (!allowOverdraft) {
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
.zeroOrPositiveAmount();
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
return;
}
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
}
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
final String securityType = stringOrStored(element, odSecurityTypeParamName,
product == null ? null : product.getOdSecurityType());
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
: product != null && product.isOdExcessAllowed();
if (excessAllowed) {
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
product == null ? null : product.getOdExcessLimitPercent());
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
.notGreaterThanMax(MAX_EXCESS_PERCENT);
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
product == null ? null : product.getOdPenalRate());
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
}
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
: product == null ? null : product.getInterestPostingPeriodType();
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
SavingsPostingInterestPeriodType.QUATERLY.getValue());
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
: product == null ? null : product.getInterestCalculationDaysInYearType();
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
}
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
: stored;
}
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
: stored;
}
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
}
}
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import com.google.gson.JsonArray;
import jakarta.persistence.CascadeType;
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private boolean odAccountClosedZeroBalanceInDayClosing = false;
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
@Column(name = "od_min_limit", scale = 6, precision = 19)
private BigDecimal odMinLimit;
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
@Column(name = "od_security_type", length = 20)
private String odSecurityType;
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
@Column(name = "od_excess_allowed")
private boolean odExcessAllowed = false;
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
private BigDecimal odExcessLimitPercent;
@Column(name = "od_penal_rate", scale = 6, precision = 19)
private BigDecimal odPenalRate;
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
private CurrentProductFacilityConfig facilityConfig;
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
this.odAccountClosedZeroBalanceInDayClosing = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
actualChanges.put(odMinLimitParamName, newValue);
this.odMinLimit = newValue;
}
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
actualChanges.put(odSecurityTypeParamName, newValue);
this.odSecurityType = newValue;
}
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
actualChanges.put(odExcessAllowedParamName, newValue);
this.odExcessAllowed = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
actualChanges.put(odExcessLimitPercentParamName, newValue);
this.odExcessLimitPercent = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
actualChanges.put(odPenalRateParamName, newValue);
this.odPenalRate = newValue;
}
if (!this.odExcessAllowed) {
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
// Clear OD-specific fields when overdraft is disabled
if (!this.allowOverdraft) {
this.avgBalanceCalcPeriod = null;
this.validForPeriod = null;
this.odAccountClosedZeroBalanceInDayClosing = false;
this.odMinLimit = null;
this.odSecurityType = null;
this.odExcessAllowed = false;
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
return actualChanges;
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
public BigDecimal getOdMinLimit() { return odMinLimit; }
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
public String getOdSecurityType() { return odSecurityType; }
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
public boolean isOdExcessAllowed() { return odExcessAllowed; }
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
public BigDecimal getOdPenalRate() { return odPenalRate; }
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
}
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
}
if (command.parameterExists(odMinLimitParamName)) {
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
}
if (command.parameterExists(odSecurityTypeParamName)) {
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
}
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
product.setOdExcessAllowed(excessAllowed);
if (excessAllowed) {
if (command.parameterExists(odExcessLimitPercentParamName)) {
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
}
if (command.parameterExists(odPenalRateParamName)) {
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
}
}
}
return product;
@@ -0,0 +1,115 @@
/**
* 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.currentproduct.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
*
* <p>
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
* the two can never meet.
*
* <p>
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
*/
@Component
@RequiredArgsConstructor
public class CurrentProductGlMappingWriter {
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
static {
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
}
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
private final JdbcTemplate jdbcTemplate;
/**
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
*
* @param accountingRule
* the product's accounting rule after this request (1 = NONE)
*/
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (accountingRule == null || accountingRule == 1) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
return changes;
}
final JsonElement parsed = command.parsedJson();
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
final String param = entry.getKey();
if (!json.has(param)) {
continue;
}
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
final Long existing = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL",
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
if (java.util.Objects.equals(existing, glAccountId)) {
continue;
}
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
if (glAccountId != null) {
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
}
changes.put(param, glAccountId);
}
return changes;
}
/** Removes every mapping of a deleted product. */
public void deleteAll(final Long productId) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
}
}
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
productData.setValidForPeriod(rs.getString("validForPeriod"));
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
productData.setOdSecurityType(rs.getString("odSecurityType"));
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return productData;
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private final CurrentProductGlMappingWriter glMappingWriter;
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
}
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
DepositAccountType.SAVINGS_DEPOSIT);
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
}
// accounting related changes
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
DepositAccountType.SAVINGS_DEPOSIT);
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
product.getAccountingType());
changes.putAll(accountingMappingChanges);
if (!changes.isEmpty()) {
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
this.currentProductRepository.delete(product);
this.glMappingWriter.deleteAll(productId);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
}
}
@@ -105,7 +105,23 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ")
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
// m_current_account -- newer write paths tag current-account entries with it
// instead of reusing SAVING's type, specifically so this case needs no guessing.
//
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
// and current-account entries by older write paths not yet updated to the above --
// current products used to deliberately share SAVING's GL type space (see
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
// m_current_account each have their own id sequence, so an entity_id can be a real
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
// account's number even for an entry that is genuinely a savings one.
// savings_transaction_id is the one unambiguous signal available for those older
// entries -- only a real savings transaction sets it -- so it decides first; only
// when it's absent does the current-account guess apply.
.append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" ELSE NULL ")
.append(" END as savingsAccountNo ");
@@ -130,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
break;
case CASH_BASED:
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
// Interest Payable is saved on cash too, and optionally: interest posting routes
// through it on either basis (see AccountingProcessorHelper
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
// yet credited to the member reads as the bank's own liability rather than appearing
// only at the moment it is paid. Mapped only when the screen supplies it - a product
// naming none still posts straight to Savings Control, as it always did.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
element)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
}
break;
case ACCRUAL_PERIODIC:
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component;
@Path("/v1/loans")
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
public class LoanCOBCatchUpApiResource {
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
private final PlatformSecurityContext context;
@GET
@Path("oldest-cob-closed")
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
public Response executeLoanCOBCatchUp() {
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
return Response.status(Response.Status.BAD_REQUEST).build();
@@ -165,9 +165,11 @@ public class SecurityConfig {
// 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
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
// since any bank's own super user reaching it could mint a platform-wide key). 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,
@@ -222,7 +224,10 @@ public class SecurityConfig {
// currency
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies"))
// the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) -
// this matcher previously targeted POST, which meant PUT fell through to the generic
// fullyAuthenticated() catch-all with no permission check at all.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
// notes: read
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
@@ -355,11 +360,18 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
// payment type
// NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a
// second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and
// no PUT/DELETE matcher existed at all, so those requests fell through to the generic
// fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher
// per method, each requiring its own correct permission code.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
// mix: taxonomy
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
@@ -390,12 +402,41 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
// working days
// NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between
// WORKING and DAYS), which did not match the actual seeded permission codes
// (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS")
// sponsor banks
// NOTE: no matcher previously existed at all for this path - POST/PUT both fell
// through to the generic fullyAuthenticated() catch-all below with no permission
// check whatsoever.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK")
// sponsor DD GST/GL settings
// NOTE: no matcher previously existed at all for this path either. gl-mapping is a
// genuine upsert (one call both creates and updates the single settings row), so it is
// covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING")
// bank profile (institution profile)
// NOTE: no matcher previously existed at all for this path - the PUT relied solely on
// the in-resource validateHasPermissionTo() check, which this change replaces with the
// command-source pipeline. Added here for the same defense-in-depth already applied to
// Currency/Payment Type/Working Days.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE")
// branch profile
// NOTE: no matcher previously existed at all for this path either, same rationale as
// institution profile above.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
@@ -20,7 +20,6 @@ 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;
@@ -35,17 +34,26 @@ 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.
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
*
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
* whoever already has direct database access -- never over HTTP.
*/
@Path("/v1/apiclients")
@Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@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);
requireSuperUser();
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
}
@GET
@@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysData;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
import org.springframework.stereotype.Component;
@@ -50,7 +50,8 @@ public class WorkingDaysApiResource {
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<WorkingDaysUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,17 +66,13 @@ public class WorkingDaysApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) {
public String update(@Valid final WorkingDaysUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays()
.withJson(toApiJsonSerializer.serialize(request)).build();
final var command = new WorkingDaysUpdateCommand();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
command.setCommandId(System.currentTimeMillis());
command.setCreatedAt(Instant.now());
command.setPayload(request);
final Supplier<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
@GET
@@ -18,38 +18,46 @@
*/
package org.apache.fineract.organisation.workingdays.handler;
import java.util.Collections;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class UpdateWorkingDaysCommandHandler implements CommandHandler<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
@Service
@CommandType(entity = "WORKINGDAYS", action = "UPDATE")
public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler {
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
@Autowired
public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) {
this.workingDaysWritePlatformService = workingDaysWritePlatformService;
}
@Transactional
@Override
public WorkingDaysUpdateResponse handle(Command<WorkingDaysUpdateRequest> command) {
WorkingDaysUpdateRequest request = command.getPayload();
Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
if (changes == null) {
changes = Collections.emptyMap();
}
public CommandProcessingResult processCommand(final JsonCommand command) {
return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes)
.recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType())
.extendTermForDailyRepayments(request.getExtendTermForDailyRepayments())
.extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build();
final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder()
.recurrence(command.stringValueOfParameterNamed("recurrence"))
.repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType"))
.extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments"))
.extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays"))
.build();
final Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(changes) //
.build();
}
}
@@ -0,0 +1,152 @@
/**
* 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.account.service;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
/**
* A book debit: money leaves a savings or current account by book entry, not as cash.
*
* <p>
* Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...).
* Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry --
* cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no
* report shows.
*
* <p>
* It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance
* (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's
* transaction, so a later failure rolls the debit back with everything else.
*/
@Service
@RequiredArgsConstructor
public class AccountBookDebitService {
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
/** Debits a savings account; returns the m_savings_account_transaction id. */
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId) {
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo());
ps.setBigDecimal(7, balance.subtract(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** Debits a current account; returns the m_current_account_transaction id. */
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId, final String note) {
final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount);
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note);
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction "
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, "
+ "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setString(5, refNo());
ps.setDate(6, sqlDate);
ps.setBigDecimal(7, balance.subtract(amount));
ps.setString(8, remarks);
ps.setLong(9, userId);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.",
accountId);
}
final int status = ((Number) rows.get(0).get("status_enum")).intValue();
if (status != 300) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active",
kind + " account " + accountId + " is not active.", accountId);
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("on_hold_funds_derived");
final BigDecimal available = balance.subtract(onHold);
if (available.compareTo(amount) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
"Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available
+ ", Requested: " + amount,
accountId);
}
return balance;
}
private static String refNo() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 20);
}
}
@@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
@@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
private final KycRestrictionGuard kycRestrictionGuard;
@Transactional
@Override
@@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
toSavingsAccount.productId(), toSavingsAccount.getId());
toSavingsAccount.productId(), false, toSavingsAccount.getId());
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
@@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromSavingsAccount.productId(), fromSavingsAccount.getId());
fromSavingsAccount.productId(), false, fromSavingsAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
@@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
toCurrentAccount.getProductId(), toCurrentAccount.getId());
toCurrentAccount.getProductId(), true, toCurrentAccount.getId());
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
@@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) {
// Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is.
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
currentAccountRepository.save(account);
if (account.getProductId() != null && txn.getId() != null) {
final String currentTxnId = "CA" + txn.getId();
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
account.getId(), currentTxnId, txnDate, amount, false);
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
account.getId(), currentTxnId, txnDate, amount);
}
return txn;
}
@@ -712,7 +715,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
*/
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
final Long counterpartEntityId) {
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -730,7 +735,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -738,7 +744,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Deposit amount into a current account and save the transaction.
*/
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
final Long counterpartEntityId) {
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.add(amount);
@@ -749,7 +757,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
final Long counterpartProductId, final Long counterpartEntityId) {
final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) {
if (account.getProductId() == null || txn.getId() == null) {
return;
}
@@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final String currentTxnId = "CA" + txn.getId();
if (isWithdrawal) {
// DR: current account GL CR: counterpart (savings/current) GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
account.getProductId(), account.getId(),
counterpartProductId, counterpartEntityId,
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
currentTxnId, txnDate, amount);
} else {
// DR: counterpart (savings/current) GL CR: current account GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartEntityId,
account.getProductId(), account.getId(),
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
currentTxnId, txnDate, amount);
}
}
@@ -1,140 +1,141 @@
/**
* 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.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
/**
* 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.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
@@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
// m_savings_account and m_current_account each keep their own identity sequence, so the same
// entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to
// be written for both savings and current-account entries alike, so esa/eca could both match the
// same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever
// client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of
// misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT)
// is what newer write paths tag current-account entries with instead, unambiguously; for older
// entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine
// savings transaction sets it), checked before the ambiguous current/savings guess.
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END,
CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6)
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
@@ -200,7 +200,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
@@ -211,7 +211,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
@@ -192,7 +192,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(), paymentTypeName);
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource {
updatedLog, Timestamp.from(Instant.now()), id
);
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
// means the cashier took the cash again, so the notes are recorded again -- without this an
// approved resubmission credited the account while the drawer never received its notes.
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
(String) record.get("account_no"), toLong(record.get("client_id")),
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
// Keep the central voucher register in sync with the edited amount
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
@@ -423,7 +434,13 @@ public class CashierDepositRequestApiResource {
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
// The account_no is also checked -- m_current_account and m_savings_account each keep
// their own identity sequence, so the same numeric id can legitimately belong to a
// current account AND an unrelated savings account at once. An id match alone once sent
// a real deposit into a stranger's current account instead of the savings account the
// request actually named; account_no is the one value that can't collide between them.
final String requestAccountNo = (String) record.get("account_no");
final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
@@ -578,7 +595,8 @@ public class CashierDepositRequestApiResource {
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
final String requestAccountNo = (String) record.get("account_no");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) {
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
}
@@ -863,11 +881,30 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/** Returns true if this account ID belongs to m_current_account (not m_savings_account). */
private boolean isCurrentAccountById(final Long accountId) {
/**
* Returns true if this account ID belongs to m_current_account (not m_savings_account).
*
* m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say)
* can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two
* different clients, sharing nothing but a coincidental number. An id-only check here once sent
* a savings deposit into a stranger's current account: id=1 existed in m_current_account, so
* this returned true, even though the request was for m_savings_account id=1.
*
* expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is
* only trusted once the account_no on that current-account row confirms it is truly the same
* account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller
* has no account_no to compare against, e.g. an id already known to be unambiguous).
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
@@ -880,12 +917,15 @@ public class CashierDepositRequestApiResource {
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*
* expectedAccountNo guards the current-account check against an id collision with
* m_savings_account — see {@link #isCurrentAccountById}.
*/
private String resolveDepositAccountCategory(final Long accountId) {
private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId)) {
if (isCurrentAccountById(accountId, expectedAccountNo)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
@@ -975,9 +1015,14 @@ public class CashierDepositRequestApiResource {
*/
// Called at submit time — uses request id as temporary txn_id.
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
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 String paymentTypeName) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
final String txnDate) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -1008,10 +1053,14 @@ public class CashierDepositRequestApiResource {
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, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
""",
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// Dated on the request's own date, which is what the branch day's book figure reads.
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
// drawer on another and leave both days' cash wrong.
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
cashierId, cashierUsername, paymentTypeName
);
}
@@ -90,7 +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 " + CountedCashLedger.COUNTED_ROW
+ "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 = ?) "
@@ -120,7 +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 " + CountedCashLedger.COUNTED_ROW
+ "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 = ?) "
@@ -60,34 +60,11 @@ public class CashReportApiResource {
* </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.
* The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
* 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) ";
private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
/**
* Day book — every voucher raised on one date, in one list, newest first.
@@ -40,6 +40,7 @@ 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.PortfolioProductType;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
@@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService {
}
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId);
accountId, accountType);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId);
@@ -340,13 +341,14 @@ public class ChequeBookIssuanceChargeService {
private Long getCurrentControlGlId(final Long currentAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) {
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
final ChequeAccountType accountType) {
final JsonArray debitArr = new JsonArray();
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", debitGlId);
@@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService {
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
final String glTxnId = result.getTransactionId();
if (glTxnId != null) {
linkDebitEntryToAccount(glTxnId, accountId);
linkDebitEntryToAccount(glTxnId, accountId, accountType);
}
}
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
/**
* Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an
* Account No.
*
* m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can
* be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of
* accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings
* account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually
* means "this id is in m_current_account" -- use it whenever accountType says so.
*/
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
@@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService {
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
final int entityType = accountType == ChequeAccountType.CURRENT
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
accountId, rowIds.get(0));
}
}
}
@@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService {
if ("CURRENT".equalsIgnoreCase(accountType)) {
return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId);
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId);
}
return null;
}
@@ -34,6 +34,7 @@ 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.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -118,7 +119,7 @@ public class ClearingJournalService {
}
final String glTxnId = result.getTransactionId();
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType);
}
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
return glTxnId;
@@ -191,7 +192,13 @@ public class ClearingJournalService {
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
/**
* Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two
* account tables number independently, so entity_type_enum must say which one accountId names, not
* assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means
* "this id is in m_current_account".
*/
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
@@ -200,8 +207,11 @@ public class ClearingJournalService {
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
final int entityType = "CURRENT".equalsIgnoreCase(accountType)
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
accountId, rowIds.get(0));
}
}
}
@@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
@@ -173,9 +174,25 @@ public class ClerkDisbursementRequestApiResource {
// 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.
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
// m_current_account -- checking it as a savings id would test some unrelated savings account.
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
: KycRestrictionGuard.Operation.CREDIT;
if ("CURRENT".equals(entryAccountType)) {
kycRestrictionGuard.checkCurrentAccount(entry.get("savingsAccountId").getAsLong(), operation);
} else {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), operation);
}
}
// A Dr leg against a savings account is a withdrawal, and the clerk who raises it should
// not learn the account can't pay out until a manager tries to approve it and the whole
// request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would
// refuse this later -- inactive, closed, or still inside its lock-in period -- is checked
// here too, against the same columns, so the clerk sees the real reason at entry time.
if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType)
&& entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt);
}
if ("Dr".equals(dbCr)) {
totalDr = totalDr.add(entryAmt);
@@ -513,6 +530,7 @@ public class ClerkDisbursementRequestApiResource {
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
// disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode).
final List<GlEntry> collectionGlEntries = new ArrayList<>();
final List<GlEntry> collectionCreditGlEntries = new ArrayList<>();
final List<GlEntry> disbursementGlEntries = new ArrayList<>();
// Compensation list: each successfully posted Fineract transaction gets an undo lambda.
@@ -534,8 +552,11 @@ public class ClerkDisbursementRequestApiResource {
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrent = "CURRENT".equals(
entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
// Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can
// exist in both m_current_account and m_savings_account at once, and a bank
// transfer posts and moves money immediately, with no maker-checker step to catch
// a wrong label before it does.
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
@@ -548,7 +569,7 @@ public class ClerkDisbursementRequestApiResource {
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
@@ -557,7 +578,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
}
@@ -610,7 +631,15 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
// submit() already raised this request's voucher (every request type does, so it
// shows in My Vouchers the moment a clerk submits it, before any checker acts) --
// minting a second one here for Bank Transfer specifically double-counted the same
// transfer under two different names, "Entered by" whoever raised it AND whoever
// approved it. Reuse that voucher; only fall back to raising a fresh one if this
// request somehow reached approval without it (defensive, not the normal path).
final Object existingVoucherNo = record.get("voucher_no");
final String btVoucherNo = existingVoucherNo != null ? (String) existingVoucherNo
: generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -633,8 +662,9 @@ public class ClerkDisbursementRequestApiResource {
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
} else {
// Collection: CR GL is the target for the combined savings debit entry
// Collection: each CR GL takes its own amount in the one combined entry
collectionTargetGlId = glId;
collectionCreditGlEntries.add(new GlEntry(glId, entryAmt, acNo, null));
}
}
txnRefs.add("GL:" + acNo);
@@ -642,7 +672,7 @@ public class ClerkDisbursementRequestApiResource {
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrentAccount = isCurrentAccountById(accountId);
final boolean isCurrentAccount = isCurrentAccountById(accountId, acNo);
String txnId = null;
if ("Cr".equals(dbCr)) {
@@ -660,7 +690,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(directCaTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true));
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
} else {
// Real CBS book transfer: direct credit to savings account.
@@ -675,32 +705,30 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(directTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false));
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
}
} else {
try {
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
} catch (IdempotentCommandProcessSucceedException idempotent) {
txnId = extractResourceIdFromResponse(idempotent.getResponse());
// Collection: a direct book debit, exactly as a bank transfer debits. It used to run a
// cash withdrawal, whose own auto-entry (DR control / CR CashVault -- cash that never
// moved) was then hidden by flagging it reversed, and a current account's auto-entry was
// not even hidden, crediting CashVault instead of the target GL. Now nothing is posted
// here; the one combined entry below is the only GL posting.
final Long ctrlGl = isCurrentAccount ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
if (ctrlGl == null) {
throw new RuntimeException("No control GL mapped for account " + acNo
+ ". Map it on the account's product Accounting tab.");
}
if (txnId != null) {
registerUndo(undoOps, accountId, txnId, isCurrentAccount);
txnRefs.add(txnId);
if (!isCurrentAccount) {
final String fullTxnId = "S" + txnId;
withdrawalSavingsTxnRefs.add(txnId);
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
// Savings withdrawal auto-creates: DR SavingsControl / CR CashVault.
// Collect the SavingsControl GL (DR side, type_enum=2) so we can
// post a direct DR SavingsControl / CR targetGL combined entry.
final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2);
if (savingsCtrlGl != null) {
collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId));
}
}
final long debitTxnId = isCurrentAccount
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(debitTxnId));
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount));
if (isCurrentAccount) {
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
} else {
undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, debitTxnId));
}
}
}
@@ -723,39 +751,22 @@ public class ClerkDisbursementRequestApiResource {
}
// ── Collection GL entry ───────────────────────────────────────────────
// Goal: DR SavingsControl(per account) / CR targetGL — one combined entry.
// Fineract withdrawals auto-create: DR SavingsControl / CR CashVault.
// We mark those auto-entries reversed (hidden from GL reports) and post
// the direct combined entry. Fallback: CashVault bridge if GL lookup fails.
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
if (!collectionGlEntries.isEmpty()
&& collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) {
markTxnGlEntriesReversed(collectionGlEntries);
final List<GlEntry> snapC = new ArrayList<>(collectionGlEntries);
undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC));
final String collGlTxnId = postCombinedJournalEntry(
collectionGlEntries,
List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)),
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Collection", collectionGlEntries, description));
// Link each DR entry to its savings account so ACCOUNT NO shows in the GL view.
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
approvedGlTxnId = collGlTxnId;
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description);
} else if (!withdrawalSavingsTxnRefs.isEmpty()) {
// No bridging entry was needed: the withdrawal's own auto-posted credit
// leg already lands on the target GL directly (the savings product's own
// payment-type mapping points there). That withdrawal's own transaction is
// therefore the real posting -- there is no second one to remember instead.
approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0);
}
// One combined entry and nothing else: DR each account's own product control GL / CR each
// target GL. The accounts were debited directly above, so there is no auto-posted entry to
// hide and no CashVault leg to bridge.
if (!collectionGlEntries.isEmpty()) {
if (collectionCreditGlEntries.isEmpty()) {
throw new RuntimeException("Collection has no credit GL to receive " + withdrawalSavingsTotal + ".");
}
final String collGlTxnId = postCombinedJournalEntry(
collectionGlEntries, collectionCreditGlEntries,
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Collection", collectionGlEntries, description));
// Link each DR entry to its savings/current account so ACCOUNT NO shows in the GL view.
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
approvedGlTxnId = collGlTxnId;
}
} // end else (disbursement/collection mode)
@@ -960,8 +971,11 @@ public class ClerkDisbursementRequestApiResource {
final Long accountId = e.get("savingsAccountId").getAsLong();
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
final boolean isCurrent = "CURRENT".equals(acTyp);
// Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can
// exist in both m_current_account and m_savings_account at once, and a bank transfer
// posts and moves money immediately, with no maker-checker step to catch a wrong
// label before it does.
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
@@ -974,7 +988,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else {
@@ -987,8 +1001,8 @@ public class ClerkDisbursementRequestApiResource {
final Long accountId = e.get("savingsAccountId").getAsLong();
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
final boolean isCurrent = "CURRENT".equals(acTyp);
// Not trusted from entry.accountType alone -- see isCurrentAccountById.
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
@@ -1000,7 +1014,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else {
@@ -1179,6 +1193,51 @@ public class ClerkDisbursementRequestApiResource {
.findFirst().orElse(null);
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
/**
* The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not
* active, or still inside its lock-in period -- checked here against the same columns, so the
* clerk sees the real reason when the request is raised rather than a generic failure once a
* manager tries to approve it. Balance is checked too, against the account's own minimum, since
* an entry that can never post is no more usable sitting in the approval queue than one refused
* outright.
*/
private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived,
min_required_balance, enforce_min_required_balance
FROM m_savings_account WHERE id = ?
""", savingsAccountId);
if (rows.isEmpty()) {
return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere
}
final Map<String, Object> row = rows.get(0);
final String accountNo = (String) row.get("account_no");
final Number statusEnum = (Number) row.get("status_enum");
if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active.");
}
final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived");
if (lockedUntil != null) {
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate lockedUntilDate = lockedUntil.toLocalDate();
if (today.isBefore(lockedUntilDate)) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo
+ ": account is locked-in until " + lockedUntilDate + ".");
}
}
final BigDecimal balance = (BigDecimal) row.get("account_balance_derived");
final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance"));
final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null
? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO;
if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance ("
+ balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ").");
}
}
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
@@ -1317,10 +1376,25 @@ public class ClerkDisbursementRequestApiResource {
commandsSourceWritePlatformService.logCommandSource(cmd);
}
private boolean isCurrentAccountById(final Long accountId) {
/**
* m_current_account and m_savings_account each keep their own identity sequence, so the same
* numeric id can be a real row in both at once -- two unrelated accounts, possibly two different
* clients, sharing nothing but a coincidental number. An id-only check here has already sent a
* real deposit into a stranger's current account instead of the savings account the entry
* actually named. expectedAccountNo -- the entry's own accountNo -- closes that gap: an id match
* is only trusted once the account_no on that current-account row confirms it. Pass null to skip
* the check where the caller has no account_no to compare against.
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank() || "?".equals(expectedAccountNo)) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
@@ -1503,20 +1577,27 @@ public class ClerkDisbursementRequestApiResource {
final BigDecimal amount;
final String accountRef; // savings account number shown in GL comments; null for source/target GL
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) {
this(glAccountId, amount, accountRef, txnId, null);
this(glAccountId, amount, accountRef, txnId, null, false);
}
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId, final Long savingsAccountId) {
this(glAccountId, amount, accountRef, txnId, savingsAccountId, false);
}
GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId,
final Long savingsAccountId, final boolean isCurrentAccount) {
this.glAccountId = glAccountId;
this.amount = amount;
this.accountRef = accountRef;
this.txnId = txnId;
this.savingsAccountId = savingsAccountId;
this.isCurrentAccount = isCurrentAccount;
}
}
@@ -1536,39 +1617,18 @@ public class ClerkDisbursementRequestApiResource {
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
}
/**
* Marks all Fineract auto-created journal entries for these transactions as reversed=true.
* Reversed entries are excluded from GL reports — only our direct combined entry is visible.
* Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails.
*/
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
// Guarded by reversed = false: if transaction_id text is ever reused, this can
// only flip rows that are currently live, never re-touch an already-reversed
// unrelated transaction's rows.
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
e.txnId);
}
}
}
/** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */
private void unmarkTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true",
e.txnId);
}
}
}
/**
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
* row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—".
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
*
* m_current_account and m_savings_account each keep their own identity sequence, so the same
* numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2
* (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view
* would resolve a current account's entry to a same-numbered savings account or vice versa.
* entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's
* entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one
* this entry's id actually is, decided once already, where the account was first resolved.
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) {
@@ -1582,11 +1642,15 @@ public class ClerkDisbursementRequestApiResource {
Long.class, glTxnId, typeEnum, entries.size());
java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
final GlEntry entry = entries.get(i);
final Long savingsAccId = entry.savingsAccountId;
if (savingsAccId != null) {
final int entityType = entry.isCurrentAccount
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
savingsAccId, rowIds.get(i));
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?",
entityType, savingsAccId, rowIds.get(i));
}
}
}
@@ -1681,7 +1745,7 @@ public class ClerkDisbursementRequestApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1",
currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
@@ -470,7 +470,7 @@ public class ClerkWithdrawalRequestApiResource {
String txnId = null;
if (savingsAccountId != null) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description);
@@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource {
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, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'WITHDRAWAL', ?, ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// The request's own date, which the branch day's paid-out figure reads -- not the
// database clock, which put the notes on a different day from the withdrawal.
record.get("txn_date"),
cashier.getId(), cashier.getUsername(), paymentTypeName
);
}
@@ -872,10 +875,25 @@ public class ClerkWithdrawalRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private boolean isCurrentAccountById(final Long accountId) {
/**
* m_current_account and m_savings_account each keep their own identity sequence, so the same
* numeric id can be a real row in both at once -- two unrelated accounts, possibly two different
* clients, sharing nothing but a coincidental number. An id-only check here can send a real
* withdrawal against a stranger's current account instead of the savings account the request
* actually named. expectedAccountNo -- the request's own account_no -- closes that gap: an id
* match is only trusted once the account_no on that current-account row confirms it. Pass null
* to skip the check where the caller has no account_no to compare against.
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
@@ -108,8 +108,20 @@ public class KycRestrictionGuard {
checkKycExpiry(savingsAccountId, operation);
}
/**
* The same two restrictions for a current account, read off the current account and its own current product
* (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's.
*/
public void checkCurrentAccount(final Long currentAccountId, final Operation operation) {
checkCurrentAccountDormancy(currentAccountId, operation);
enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation);
}
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
final Long daysOverdue = daysOverdue(savingsAccountId);
enforceKycStage(daysOverdue(savingsAccountId), operation);
}
private void enforceKycStage(final Long daysOverdue, final Operation operation) {
if (daysOverdue == null) {
return;
}
@@ -158,6 +170,28 @@ public class KycRestrictionGuard {
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
""", savingsAccountId);
refuseIfDormant(rows, operation, "savings");
}
private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) {
if (currentAccountId == null || operation == Operation.CREDIT) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT cp.days_to_dormancy,
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
- GREATEST(ca.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = ca.client_id AND k.verified_on IS NOT NULL),
ca.activatedon_date)) AS days_idle
FROM m_current_account ca
JOIN m_current_product cp ON cp.id = ca.product_id
WHERE ca.id = ? AND cp.is_dormancy_tracking_active = true AND cp.days_to_dormancy > 0
""", currentAccountId);
refuseIfDormant(rows, operation, "current");
}
private void refuseIfDormant(final List<Map<String, Object>> rows, final Operation operation, final String accountKind) {
if (rows.isEmpty()) {
return;
}
@@ -168,11 +202,30 @@ public class KycRestrictionGuard {
}
final long years = threshold.longValue() / 365;
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
throw new PlatformDataIntegrityException("error.msg." + accountKind + ".account.dormant." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
}
private Long currentAccountDaysOverdue(final Long currentAccountId) {
if (currentAccountId == null) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
FROM m_current_account ca
JOIN m_client_kyc k ON k.client_id = ca.client_id
WHERE ca.id = ? AND k.expiry_date IS NOT NULL
ORDER BY k.expiry_date DESC
LIMIT 1
""", currentAccountId);
if (rows.isEmpty()) {
return null;
}
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
}
/**
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
*
@@ -87,4 +87,53 @@ public final class CountedCashLedger {
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
/**
* {@link #REAL_TXN_ID} plus the two tests a real id can still fail, as one predicate over the alias
* {@code std}. Every drawer figure reads this, not {@code REAL_TXN_ID} alone.
*
* <p>
* Only the cashier position report used to apply the two extra tests, so it and the reconciliation
* screen disagreed: a reversed ₹200 deposit left the settlement screen expecting three ₹100 notes
* while the settlement check, reading the position, allowed one — and refused the hand-over.
*
* <ul>
* <li>a breakdown whose savings transaction is reversed -- the money went back, so the notes did too.
* The table has no reversal marker of its own, so it is read off the transaction;
* <li>a breakdown that does not add up to its transaction -- ₹1,60,000 of notes against a ₹918
* deposit is mis-keyed, not cash. Sub-rupee tolerance, because a paisa-exact amount is handed
* over as the nearest whole rupee.
* </ul>
*
* <p>
* <b>The transaction is found by id and account number together.</b> Savings and current
* transactions keep separate id sequences, and a current-account deposit writes its own
* transaction id into {@code txn_id}. Matched on id alone, a ₹210 current-account deposit was
* judged against an unrelated savings transaction that happened to share its id, failed the
* amount test, and vanished from the cashier's drawer. A row with no account number (older
* writers) is still matched on id alone, as before.
*
* <p>
* All tests are correlated {@code NOT EXISTS} rather than joins, so a breakdown with no account
* transaction behind it (a Demand Draft, a manual GL entry) is untouched by them.
*/
public static final String COUNTED_ROW = " (NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt"
+ " JOIN m_savings_account rsa ON rsa.id = rt.savings_account_id"
+ " WHERE rt.id = std.txn_id"
+ " AND (std.account_no IS NULL OR rsa.account_no = std.account_no)"
+ " AND (rt.is_reversed = true OR " + breakdownDiffersFrom("rt.amount") + "))"
+ " AND NOT EXISTS ("
+ " SELECT 1 FROM m_current_account_transaction rct"
+ " JOIN m_current_account rca ON rca.id = rct.current_account_id"
+ " WHERE rct.id = std.txn_id AND rca.account_no = std.account_no"
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
+ " AND" + REAL_TXN_ID + ") ";
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
private static String breakdownDiffersFrom(final String amountColumn) {
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
}
}
@@ -286,7 +286,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " 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 = ?)"
+ " GROUP BY std.denomination_id"
@@ -310,7 +310,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " 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 = ?)"
+ " GROUP BY std.denomination_id"
@@ -407,7 +407,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " 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 = ?)"
+ " GROUP BY std.denomination_id"
@@ -431,7 +431,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " 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 = ?)"
+ " GROUP BY std.denomination_id"
@@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
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.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
@@ -114,4 +115,16 @@ public class FundsApiResource {
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return toApiJsonSerializer.serialize(result);
}
@DELETE
@Path("{fundId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete a Fund", description = "Deletes a fund outright. Fails if the fund is still referenced by any loan or loan product.")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FundsApiResourceSwagger.DeleteFundsFundIdResponse.class)))
public String deleteFund(@PathParam("fundId") @Parameter(description = "fundId") final Long fundId) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().deleteFund(fundId).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return toApiJsonSerializer.serialize(result);
}
}
@@ -55,4 +55,13 @@ final class FundsApiResourceSwagger {
public Long resourceId;
public PutFundsFundIdRequest changes;
}
@Schema(description = "DeleteFundsFundIdResponse")
public static final class DeleteFundsFundIdResponse {
private DeleteFundsFundIdResponse() {}
@Schema(example = "1")
public Long resourceId;
}
}
@@ -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.portfolio.fund.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.fund.service.FundWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FUND", action = "DELETE")
public class DeleteFundCommandHandler implements NewCommandSourceHandler {
private final FundWritePlatformService writePlatformService;
@Autowired
public DeleteFundCommandHandler(final FundWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteFund(command.entityId(), command);
}
}
@@ -26,4 +26,6 @@ public interface FundWritePlatformService {
CommandProcessingResult createFund(JsonCommand command);
CommandProcessingResult updateFund(Long fundId, JsonCommand command);
CommandProcessingResult deleteFund(Long fundId, JsonCommand command);
}
@@ -35,6 +35,7 @@ import org.apache.fineract.portfolio.fund.exception.FundNotFoundException;
import org.apache.fineract.portfolio.fund.serialization.FundCommandFromApiJsonDeserializer;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -45,6 +46,7 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf
private final PlatformSecurityContext context;
private final FundCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final FundRepository fundRepository;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -99,6 +101,45 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf
}
}
@Transactional
@Override
@CacheEvict(value = "funds", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat('fn')")
public CommandProcessingResult deleteFund(final Long fundId, final JsonCommand command) {
this.context.authenticatedUser();
final Fund fund = this.fundRepository.findById(fundId).orElseThrow(() -> new FundNotFoundException(fundId));
requireNoneReference(fundId, "m_loan", "fund_id", "loan", "loans",
"Reassign or remove the loans using this fund before deleting it.");
requireNoneReference(fundId, "m_product_loan", "fund_id", "loan product", "loan products",
"Remove this fund from the loan products using it before deleting it.");
try {
this.fundRepository.delete(fund);
this.fundRepository.flush();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references",
"This fund cannot be deleted: other records still reference it.");
} catch (final PersistenceException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references",
"This fund cannot be deleted: other records still reference it.");
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(fundId).build();
}
private void requireNoneReference(final Long fundId, 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,
fundId);
if (count != null && count > 0) {
final String noun = count == 1 ? singular : plural;
throw new PlatformDataIntegrityException("error.msg.fund.cannot.delete.still.referenced",
"This fund cannot be deleted: it is still referenced by " + count + " " + noun + ". " + remedy, "fundId", fundId);
}
}
/*
* Guaranteed to throw an exception no matter what the data integrity issue is.
*/
@@ -42,7 +42,7 @@ public class FundConfiguration {
@Bean
@ConditionalOnMissingBean(FundWritePlatformService.class)
public FundWritePlatformService fundWritePlatformService(PlatformSecurityContext context,
FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository) {
return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository);
FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository, JdbcTemplate jdbcTemplate) {
return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository, jdbcTemplate);
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.orgsetup.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
@@ -27,17 +28,18 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -57,6 +59,7 @@ public class BranchProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
@GET
@@ -84,68 +87,23 @@ public class BranchProfileApiResource {
@PUT
@Path("{officeId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("officeId") final Long officeId, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE");
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?",
Integer.class, officeId);
if (officeExists == null || officeExists == 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
public CommandProcessingResult update(@PathParam("officeId") final Long officeId, final String body) {
// The write itself (office-exists check, branch-code uniqueness check, m_office_profile
// upsert) now lives in BranchProfileWritePlatformService, invoked via
// UpdateBranchProfileCommandHandler so it goes through the generic Maker-Checker gate. The
// permission check that used to happen here explicitly is now enforced by SecurityConfig's
// matcher plus the command-source pipeline itself, the same as every other endpoint already
// migrated to this pipeline. A "locale" key is added if the body does not already carry one -
// JsonCommand's BigDecimal extractor (for latitude/longitude) requires it, and the frontend's
// payload does not currently send one.
final var root = this.fromJsonHelper.parse(body);
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
final String address = this.fromJsonHelper.extractStringNamed("address", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root);
final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root);
final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root);
final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root);
final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root);
final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root);
if (branchCode != null && !branchCode.isBlank()) {
final Integer dupe = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
branchCode, officeId);
if (dupe != null && dupe > 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
"Branch code '" + branchCode + "' is already used by another branch.");
}
final JsonObject json = root.getAsJsonObject();
if (!json.has("locale")) {
json.addProperty("locale", "en");
}
final AppUser currentUser = this.context.authenticatedUser();
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ?
WHERE office_id = ?
""", branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId);
} else {
jdbcTemplate.update("""
INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()));
}
return list(officeId);
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateBranchProfile(officeId)
.withJson(this.gson.toJson(json)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private String baseSql() {
@@ -25,14 +25,16 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -60,6 +62,7 @@ public class InstitutionProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
@GET
@@ -89,100 +92,17 @@ public class InstitutionProfileApiResource {
@PUT
@Consumes({ MediaType.APPLICATION_JSON })
public String update(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE");
ensureRowExists();
final var root = this.fromJsonHelper.parse(body);
final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root);
final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root);
final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root);
final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root);
final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root);
final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root);
final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root);
final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root);
final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root);
final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root);
final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root));
final String status = this.fromJsonHelper.extractStringNamed("status", root);
final String country = this.fromJsonHelper.extractStringNamed("country", root);
final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root);
final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root));
final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root);
final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root));
final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root);
final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root);
final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root);
final String city = this.fromJsonHelper.extractStringNamed("city", root);
final String district = this.fromJsonHelper.extractStringNamed("district", root);
final String state = this.fromJsonHelper.extractStringNamed("state", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE;
final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root);
final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE;
final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root);
final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root);
final String website = this.fromJsonHelper.extractStringNamed("website", root);
final String pan = this.fromJsonHelper.extractStringNamed("pan", root);
final String tan = this.fromJsonHelper.extractStringNamed("tan", root);
final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root);
final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root);
final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root);
final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root);
final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root);
final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root);
final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation",
root);
final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root);
final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root);
// A status flip is stamped automatically the first time it happens, rather than asking the
// user to also remember to set a date — the previous row's own status is what "the first
// time" is judged against.
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
String.class);
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
final LocalDate today = LocalDate.now();
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
UPDATE m_institution_profile
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
swift_bic = ?,
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
head_office_same_as_registered = ?, head_office_address = ?,
communication_same_as_registered = ?, communication_address = ?,
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
website = ?, pan = ?, tan = ?, gstin = ?,
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
terms_and_conditions = ?,
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
authorized_person_email = ?,
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
updated_by_username = ?, updated_at = ?
WHERE id = 1
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country,
regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic,
addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress,
communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail,
website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions,
authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail,
turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(),
Timestamp.from(Instant.now()));
return get();
public CommandProcessingResult update(final String body) {
// The write itself (the JDBC UPDATE of m_institution_profile) now lives in
// InstitutionProfileWritePlatformService, invoked via UpdateInstitutionProfileCommandHandler so
// it goes through the generic Maker-Checker gate. The permission check that used to happen here
// explicitly is now enforced by SecurityConfig's matcher plus the command-source pipeline
// itself, the same as every other endpoint already migrated to this pipeline (Currency, Working
// Days, Payment Type). `body` is forwarded to the command as-is - it already carries the
// `locale` key the frontend sends, which JsonCommand's BigDecimal/date extractors would
// otherwise require.
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateInstitutionProfile().withJson(body).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private void ensureRowExists() {
@@ -204,14 +124,6 @@ public class InstitutionProfileApiResource {
return codes.isEmpty() ? null : codes.get(0);
}
private LocalDate parseDate(final String raw) {
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
}
private Date toSqlDate(final LocalDate d) {
return d == null ? null : Date.valueOf(d);
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("bankName", row.get("bank_name"));
@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.orgsetup.service.BranchProfileWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "BRANCHPROFILE", action = "UPDATE")
public class UpdateBranchProfileCommandHandler implements NewCommandSourceHandler {
private final BranchProfileWritePlatformService branchProfileWritePlatformService;
@Autowired
public UpdateBranchProfileCommandHandler(final BranchProfileWritePlatformService branchProfileWritePlatformService) {
this.branchProfileWritePlatformService = branchProfileWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final Long officeId = command.entityId();
final CommandProcessingResult result = this.branchProfileWritePlatformService.updateBranchProfile(officeId,
command.stringValueOfParameterNamed("branchCode"), command.stringValueOfParameterNamed("branchType"),
command.stringValueOfParameterNamed("address"), command.stringValueOfParameterNamed("pincode"),
command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"),
command.stringValueOfParameterNamed("workingHoursStart"),
command.stringValueOfParameterNamed("workingHoursEnd"), command.stringValueOfParameterNamed("workingDays"),
command.stringValueOfParameterNamed("ifscCode"), command.stringValueOfParameterNamed("micrCode"),
command.bigDecimalValueOfParameterNamed("latitude"), command.bigDecimalValueOfParameterNamed("longitude"));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.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.portfolio.orgsetup.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.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.orgsetup.service.InstitutionProfileWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INSTITUTIONPROFILE", action = "UPDATE")
public class UpdateInstitutionProfileCommandHandler implements NewCommandSourceHandler {
private final InstitutionProfileWritePlatformService institutionProfileWritePlatformService;
@Autowired
public UpdateInstitutionProfileCommandHandler(
final InstitutionProfileWritePlatformService institutionProfileWritePlatformService) {
this.institutionProfileWritePlatformService = institutionProfileWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean headOfficeSame = command.parameterExists("headOfficeSameAsRegistered")
? command.booleanObjectValueOfParameterNamed("headOfficeSameAsRegistered") : Boolean.TRUE;
final Boolean communicationSame = command.parameterExists("communicationSameAsRegistered")
? command.booleanObjectValueOfParameterNamed("communicationSameAsRegistered") : Boolean.TRUE;
final CommandProcessingResult result = this.institutionProfileWritePlatformService.updateInstitutionProfile(
command.stringValueOfParameterNamed("bankName"), command.stringValueOfParameterNamed("legalName"),
command.stringValueOfParameterNamed("shortName"), command.stringValueOfParameterNamed("bankCode"),
command.stringValueOfParameterNamed("institutionCode"), command.stringValueOfParameterNamed("bankType"),
command.stringValueOfParameterNamed("ownershipType"),
command.stringValueOfParameterNamed("parentInstitutionName"),
command.stringValueOfParameterNamed("registrationNumber"),
command.stringValueOfParameterNamed("licenseNumber"), command.stringValueOfParameterNamed("establishedDate"),
command.stringValueOfParameterNamed("status"), command.stringValueOfParameterNamed("country"),
command.stringValueOfParameterNamed("regulatoryAuthority"),
command.stringValueOfParameterNamed("registrationDate"),
command.stringValueOfParameterNamed("licenseStatus"),
command.stringValueOfParameterNamed("licenseExpiryDate"), command.stringValueOfParameterNamed("swiftBic"),
command.stringValueOfParameterNamed("addressLine1"), command.stringValueOfParameterNamed("addressLine2"),
command.stringValueOfParameterNamed("city"), command.stringValueOfParameterNamed("district"),
command.stringValueOfParameterNamed("state"), command.stringValueOfParameterNamed("pincode"), headOfficeSame,
command.stringValueOfParameterNamed("headOfficeAddress"), communicationSame,
command.stringValueOfParameterNamed("communicationAddress"),
command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"),
command.stringValueOfParameterNamed("customerCareNumber"),
command.stringValueOfParameterNamed("grievanceEmail"), command.stringValueOfParameterNamed("website"),
command.stringValueOfParameterNamed("pan"), command.stringValueOfParameterNamed("tan"),
command.stringValueOfParameterNamed("gstin"), command.stringValueOfParameterNamed("statementHeaderName"),
command.stringValueOfParameterNamed("passbookHeaderName"),
command.stringValueOfParameterNamed("receiptHeaderName"),
command.stringValueOfParameterNamed("termsAndConditions"),
command.stringValueOfParameterNamed("authorizedPersonName"),
command.stringValueOfParameterNamed("authorizedPersonDesignation"),
command.stringValueOfParameterNamed("authorizedPersonContact"),
command.stringValueOfParameterNamed("authorizedPersonEmail"));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.build();
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.service;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface BranchProfileWritePlatformService {
CommandProcessingResult updateBranchProfile(Long officeId, String branchCode, String branchType, String address,
String pincode, String contactPhone, String contactEmail, String workingHoursStart, String workingHoursEnd,
String workingDays, String ifscCode, String micrCode, BigDecimal latitude, BigDecimal longitude);
}
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
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.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Moved verbatim out of {@code BranchProfileApiResource.update()} so the write can be invoked from a
* {@code NewCommandSourceHandler} for Maker-Checker support. The resource's own {@code list()} (read)
* method is untouched.
*/
@Service
public class BranchProfileWritePlatformServiceImpl implements BranchProfileWritePlatformService {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Autowired
public BranchProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) {
this.jdbcTemplate = jdbcTemplate;
this.context = context;
}
@Transactional
@Override
public CommandProcessingResult updateBranchProfile(final Long officeId, final String branchCode, final String branchType,
final String address, final String pincode, final String contactPhone, final String contactEmail,
final String workingHoursStart, final String workingHoursEnd, final String workingDays, final String ifscCode,
final String micrCode, final BigDecimal latitude, final BigDecimal longitude) {
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", Integer.class,
officeId);
if (officeExists == null || officeExists == 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
if (branchCode != null && !branchCode.isBlank()) {
final Integer dupe = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
branchCode, officeId);
if (dupe != null && dupe > 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
"Branch code '" + branchCode + "' is already used by another branch.");
}
}
final AppUser currentUser = this.context.authenticatedUser();
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ?
WHERE office_id = ?
""", branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId);
} else {
jdbcTemplate.update("""
INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()));
}
return new CommandProcessingResultBuilder().withEntityId(officeId).build();
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.service;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InstitutionProfileWritePlatformService {
CommandProcessingResult updateInstitutionProfile(String bankName, String legalName, String shortName, String bankCode,
String institutionCode, String bankType, String ownershipType, String parentInstitutionName,
String registrationNumber, String licenseNumber, String establishedDate, String status, String country,
String regulatoryAuthority, String registrationDate, String licenseStatus, String licenseExpiryDate,
String swiftBic, String addressLine1, String addressLine2, String city, String district, String state,
String pincode, Boolean headOfficeSameAsRegistered, String headOfficeAddress,
Boolean communicationSameAsRegistered, String communicationAddress, String contactPhone, String contactEmail,
String customerCareNumber, String grievanceEmail, String website, String pan, String tan, String gstin,
String statementHeaderName, String passbookHeaderName, String receiptHeaderName, String termsAndConditions,
String authorizedPersonName, String authorizedPersonDesignation, String authorizedPersonContact,
String authorizedPersonEmail);
}
@@ -0,0 +1,134 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.service;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Moved verbatim out of {@code InstitutionProfileApiResource.update()} so the write can be invoked
* from a {@code NewCommandSourceHandler} for Maker-Checker support. {@code ensureRowExists()},
* {@code parseDate()} and {@code toSqlDate()} are copies of the resource's own private helpers rather
* than shared ones - the resource's untouched {@code get()} method keeps its own copies so this change
* does not have to touch that unrelated read path at all.
*/
@Service
public class InstitutionProfileWritePlatformServiceImpl implements InstitutionProfileWritePlatformService {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Autowired
public InstitutionProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) {
this.jdbcTemplate = jdbcTemplate;
this.context = context;
}
@Transactional
@Override
public CommandProcessingResult updateInstitutionProfile(final String bankName, final String legalName,
final String shortName, final String bankCode, final String institutionCode, final String bankType,
final String ownershipType, final String parentInstitutionName, final String registrationNumber,
final String licenseNumber, final String establishedDate, final String status, final String country,
final String regulatoryAuthority, final String registrationDate, final String licenseStatus,
final String licenseExpiryDate, final String swiftBic, final String addressLine1, final String addressLine2,
final String city, final String district, final String state, final String pincode,
final Boolean headOfficeSameAsRegistered, final String headOfficeAddress,
final Boolean communicationSameAsRegistered, final String communicationAddress, final String contactPhone,
final String contactEmail, final String customerCareNumber, final String grievanceEmail, final String website,
final String pan, final String tan, final String gstin, final String statementHeaderName,
final String passbookHeaderName, final String receiptHeaderName, final String termsAndConditions,
final String authorizedPersonName, final String authorizedPersonDesignation, final String authorizedPersonContact,
final String authorizedPersonEmail) {
ensureRowExists();
// A status flip is stamped automatically the first time it happens, rather than asking the
// user to also remember to set a date — the previous row's own status is what "the first
// time" is judged against.
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
String.class);
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
final LocalDate today = LocalDate.now();
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
UPDATE m_institution_profile
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
swift_bic = ?,
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
head_office_same_as_registered = ?, head_office_address = ?,
communication_same_as_registered = ?, communication_address = ?,
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
website = ?, pan = ?, tan = ?, gstin = ?,
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
terms_and_conditions = ?,
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
authorized_person_email = ?,
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
updated_by_username = ?, updated_at = ?
WHERE id = 1
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(parseDate(establishedDate)), status, country,
regulatoryAuthority, toSqlDate(parseDate(registrationDate)), licenseStatus, toSqlDate(parseDate(licenseExpiryDate)),
swiftBic, addressLine1, addressLine2, city, district, state, pincode, headOfficeSameAsRegistered,
headOfficeAddress, communicationSameAsRegistered, communicationAddress, contactPhone, contactEmail,
customerCareNumber, grievanceEmail, website, pan, tan, gstin, statementHeaderName, passbookHeaderName,
receiptHeaderName, termsAndConditions, authorizedPersonName, authorizedPersonDesignation,
authorizedPersonContact, authorizedPersonEmail, turningActive, toSqlDate(today), turningInactive,
toSqlDate(today), currentUser.getUsername(), Timestamp.from(Instant.now()));
return new CommandProcessingResultBuilder().withEntityId(1L).build();
}
private void ensureRowExists() {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1",
Integer.class);
if (count == null || count == 0) {
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
INSERT INTO m_institution_profile (id, status, created_by_username, created_at)
VALUES (1, 'ACTIVE', ?, ?)
""", currentUser.getUsername(), Timestamp.from(Instant.now()));
}
}
private LocalDate parseDate(final String raw) {
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
}
private Date toSqlDate(final LocalDate d) {
return d == null ? null : Date.valueOf(d);
}
}
@@ -0,0 +1,850 @@
/**
* 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.ossreturn.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.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
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;
/**
* Live computation of the RBI off-site surveillance returns OSS 1 (RAL) and OSS 2 (RoE).
*
* <h3>What these returns are</h3>
*
* OSS 1 is the bank's balance sheet restated on RBI's own line structure; OSS 2 is its profit and
* loss account on the same footing. Both are quarterly, both are denominated in thousands of
* rupees, and both are filed through RBI's CIMS portal.
*
* <h3>Where every figure comes from</h3>
*
* Nothing here is a constant. Each return line names a role; the GL accounts pinned to that role
* decide the figure, and the ledger decides its value:
*
* <ul>
* <li><b>Balance-sheet roles</b> (OSS 1) take the closing balance as at the quarter end - every
* journal entry on or before that date, on the account's own natural side.</li>
* <li><b>Earnings roles</b> (OSS 2) take the movement over the quarter, because an income or
* expense line reports what was earned or spent in the period, not a balance carried forward.</li>
* <li><b>Counts</b> (depositors, borrowers, members) come from the portfolio tables, since a
* headcount has no ledger balance to read.</li>
* </ul>
*
* The bank's own identity - name, RBI bank code, regional office, signatories - is read from
* {@code m_institution_profile}, the single place the institution's particulars are maintained.
* Nothing about the bank is written into this file.
*
* <h3>Blank is not zero</h3>
*
* A role with no accounts pinned to it reports {@code null} and says so in
* {@code unmetRequirements}. It never reports 0.00. To RBI a nil figure is an assertion that the
* bank holds nothing under that head; an unmapped role is an assertion about nothing at all, and
* the two must not be filed as though they were the same statement.
*/
@Path("/v1/ossreturn")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class OssReturnApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
/** Returns are denominated in thousands of rupees; the ledger is in rupees. */
private static final BigDecimal RETURN_SCALE = BigDecimal.valueOf(1000);
/**
* acc_gl_account.classification_enum. Only the debit-natured classifications are named, since
* they are what decides which way round a balance is read; everything else is credit-natured.
*/
private static final int ASSET = 1;
private static final int INCOME = 4;
private static final int EXPENSE = 5;
/** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */
private static final int POSTABLE_ACCOUNT = 1;
/**
* Pseudo-role for the year's own result. Not a configurable role: it is derived from whatever
* income and expense accounts exist, so it cannot be left unmapped and cannot be mapped wrong.
*/
private static final String CURRENT_YEAR_RESULT = "__CURRENT_YEAR_RESULT__";
/** m_savings_account.status_enum for an active account. */
private static final int SAVINGS_ACTIVE = 300;
/** m_loan.loan_status_id for an active loan. */
private static final int LOAN_ACTIVE = 300;
/**
* m_savings_account.deposit_type_enum, per DepositAccountType. RBI's "Term Deposits" head
* covers both fixed and recurring deposits, so the two are counted together there.
*/
private static final int SAVINGS_DEPOSIT = 100;
private static final List<Integer> TERM_DEPOSIT_TYPES = List.of(200, 300);
/** How RBI prints dates on the General Information sheet. */
private static final DateTimeFormatter RBI_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH);
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
// ─────────────────────────── OSS 1 — RAL ───────────────────────────
/**
* OSS 1 - Return on Assets and Liabilities, return code R070.
*
* @param quarterEndDateParam
* any date in the quarter being reported; the quarter end is derived from it, so a
* caller never has to know which day a quarter ends on. Defaults to the current
* business date.
*/
@GET
@Path("oss1")
public String oss1(@QueryParam("quarterEndDate") final String quarterEndDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate requested = parseOrBusinessDate(quarterEndDateParam);
final Quarter quarter = Quarter.containing(requested);
final Map<String, Object> response = baseResponse("R070", "Return on Assets and Liabilities (RAL)", quarter,
officeId);
response.put("basis", Map.of("asOnDate", quarter.end.toString(), "note",
"Balance-sheet figures are the closing position as at " + quarter.end
+ "; every journal entry on or before that date is included."));
// Liabilities and assets, in the order the RBI workbook prints them. Each line names the
// role whose mapped accounts produce it; a line with no role of its own is a subtotal and
// is summed from the lines it covers, never mapped separately.
final List<Map<String, Object>> liabilities = new ArrayList<>();
final RoleFigures figures = new RoleFigures(quarter, officeId);
liabilities.add(balanceLine("1. Paid-up Capital", "OSS1_PAID_UP_CAPITAL", figures));
liabilities.add(balanceLine("2. Reserve Fund and other Reserves", "OSS1_RESERVES", figures));
liabilities.add(balanceLine("3. Profit (+) / Loss (-)", "OSS1_PROFIT_LOSS", figures));
// The year's own result, which the ledger has not appropriated to reserves yet. RBI prints
// it as its own head for exactly that reason: between appropriations the balance sheet is
// short by what the bank has earned or lost so far, and without this line the return would
// not balance even though the books do. Derived from the income and expense accounts rather
// than mapped, since it is not a balance any account carries.
final BigDecimal currentYearResult = currentYearResult(quarter, officeId);
liabilities.add(derivedLine(
currentYearResult.signum() >= 0 ? "3 iii. Operating Surplus in Current year"
: "3 iv. Operating Deficit in Current year",
CURRENT_YEAR_RESULT, currentYearResult));
liabilities.add(subtotal("4. Total Capital And Reserves", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES",
"OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT));
liabilities.add(balanceLine("5. Subordinated Debt (Tier II Capital)", "OSS1_SUBORDINATED_DEBT", figures));
liabilities.add(balanceLine("6. Deposits and other Accounts", "OSS1_DEPOSITS", figures));
liabilities.add(countLine("Total No of Depositors", countDepositors(officeId, quarter.end, null)));
liabilities.add(balanceLine("i. Term Deposits (including recurring deposits)", "OSS1_DEPOSITS_TERM", figures));
liabilities.add(
countLine("No. of Term Depositors", countDepositors(officeId, quarter.end, TERM_DEPOSIT_TYPES)));
liabilities.add(balanceLine("ii. Savings Bank Deposits", "OSS1_DEPOSITS_SAVINGS", figures));
liabilities.add(countLine("No of Savings Depositors",
countDepositors(officeId, quarter.end, List.of(SAVINGS_DEPOSIT))));
liabilities.add(balanceLine("iii. Current Deposits", "OSS1_DEPOSITS_CURRENT", figures));
liabilities.add(balanceLine("7. Borrowings", "OSS1_BORROWINGS", figures));
liabilities.add(balanceLine("8. Other Liabilities and Provisions", "OSS1_OTHER_LIABILITIES", figures));
// The deposit sub-heads are a breakdown of OSS1_DEPOSITS, not additions to it, so the total
// names the head and leaves them out rather than counting the same money twice.
liabilities.add(subtotal("15. Total Liabilities", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES",
"OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT, "OSS1_SUBORDINATED_DEBT", "OSS1_DEPOSITS", "OSS1_BORROWINGS",
"OSS1_OTHER_LIABILITIES"));
final List<Map<String, Object>> assets = new ArrayList<>();
assets.add(balanceLine("1. Cash in Hand", "OSS1_CASH_IN_HAND", figures));
assets.add(balanceLine("2. Balances with Banks", "OSS1_BALANCE_WITH_BANKS", figures));
assets.add(balanceLine("3. Investments", "OSS1_INVESTMENTS", figures));
assets.add(balanceLine("4. Loans and Advances", "OSS1_LOANS_ADVANCES", figures));
assets.add(countLine("Total No of Borrowers", borrowerCount(officeId, quarter.end)));
assets.add(balanceLine("5. Fixed Assets", "OSS1_FIXED_ASSETS", figures));
assets.add(balanceLine("6. Other Assets", "OSS1_OTHER_ASSETS", figures));
assets.add(subtotal("12. Total Assets", assets, "OSS1_CASH_IN_HAND", "OSS1_BALANCE_WITH_BANKS",
"OSS1_INVESTMENTS", "OSS1_LOANS_ADVANCES", "OSS1_FIXED_ASSETS", "OSS1_OTHER_ASSETS"));
response.put("liabilities", liabilities);
response.put("assets", assets);
// Liabilities must equal assets. Reported rather than asserted: an imbalance is a real
// condition of the books that the filer has to see, not something to hide behind an error.
final BigDecimal totalLiabilities = amountOf(liabilities, "15. Total Liabilities");
final BigDecimal totalAssets = amountOf(assets, "12. Total Assets");
response.put("totalLiabilities", totalLiabilities);
response.put("totalAssets", totalAssets);
response.put("balanced", totalLiabilities.compareTo(totalAssets) == 0);
response.put("imbalance", totalAssets.subtract(totalLiabilities));
response.put("unmetRequirements", figures.unmet());
response.put("computable", figures.anyConfigured());
return gson.toJson(response);
}
// ─────────────────────────── OSS 2 — RoE ───────────────────────────
/**
* OSS 2 - Return on Earnings, return code R071.
*
* Earnings lines report the movement over the quarter rather than a closing balance, so the
* period matters here in a way it does not on OSS 1: the same account read as a balance would
* carry forward every earlier quarter's income as well.
*/
@GET
@Path("oss2")
public String oss2(@QueryParam("quarterEndDate") final String quarterEndDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate requested = parseOrBusinessDate(quarterEndDateParam);
final Quarter quarter = Quarter.containing(requested);
final Map<String, Object> response = baseResponse("R071", "Return on Earnings (RoE)", quarter, officeId);
response.put("basis", Map.of("fromDate", quarter.start.toString(), "toDate", quarter.end.toString(), "note",
"Earnings are the movement over " + quarter.start + " to " + quarter.end
+ ", not a closing balance: an income or expense line reports what was earned or spent "
+ "in the quarter."));
final RoleFigures figures = new RoleFigures(quarter, officeId);
final List<Map<String, Object>> earnings = new ArrayList<>();
earnings.add(movementLine("2. Interest/discount received on loans and advances", "OSS2_INTEREST_ON_ADVANCES",
figures));
earnings.add(movementLine("3. Interest on market lending (to banks)", "OSS2_INTEREST_ON_MARKET_LENDING",
figures));
earnings.add(movementLine("4. Interest on investments", "OSS2_INTEREST_ON_INVESTMENTS", figures));
earnings.add(movementLine("5. Interest on balances with RBI and inter-bank deposits",
"OSS2_INTEREST_ON_BANK_BALANCES", figures));
earnings.add(subtotal("1. Interest/discount received (2+3+4+5)", earnings, "OSS2_INTEREST_ON_ADVANCES",
"OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES"));
earnings.add(movementLine("6. Commission, Exchange and Brokerage", "OSS2_COMMISSION_EXCHANGE_BROKERAGE",
figures));
earnings.add(movementLine("7. Profit on sale of investments", "OSS2_PROFIT_ON_INVESTMENTS", figures));
earnings.add(movementLine("8. Other income", "OSS2_OTHER_INCOME", figures));
final List<Map<String, Object>> expenses = new ArrayList<>();
expenses.add(movementLine("10. Interest paid on deposits", "OSS2_INTEREST_PAID_ON_DEPOSITS", figures));
expenses.add(movementLine("11. Interest paid on borrowings", "OSS2_INTEREST_PAID_ON_BORROWINGS", figures));
expenses.add(movementLine("12. Salaries, allowances and provident fund", "OSS2_STAFF_COST", figures));
expenses.add(movementLine("13. Rent, taxes, insurance and lighting", "OSS2_RENT_TAXES_INSURANCE", figures));
expenses.add(movementLine("14. Depreciation on bank's property", "OSS2_DEPRECIATION", figures));
expenses.add(movementLine("15. Provisions and contingencies", "OSS2_PROVISIONS", figures));
expenses.add(movementLine("16. Other operating expenses", "OSS2_OTHER_OPERATING_EXPENSES", figures));
response.put("earnings", earnings);
response.put("expenses", expenses);
// Item 1 is the sum of items 2 to 5, so total income counts the four components and the
// standalone heads - never item 1 as well, which would count that interest twice.
final BigDecimal totalIncome = sumOfRoles(earnings, "OSS2_INTEREST_ON_ADVANCES",
"OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES",
"OSS2_COMMISSION_EXCHANGE_BROKERAGE", "OSS2_PROFIT_ON_INVESTMENTS", "OSS2_OTHER_INCOME");
final BigDecimal totalExpenditure = sumOfRoles(expenses, "OSS2_INTEREST_PAID_ON_DEPOSITS",
"OSS2_INTEREST_PAID_ON_BORROWINGS", "OSS2_STAFF_COST", "OSS2_RENT_TAXES_INSURANCE",
"OSS2_DEPRECIATION", "OSS2_PROVISIONS", "OSS2_OTHER_OPERATING_EXPENSES");
response.put("totalIncome", totalIncome);
response.put("totalExpenditure", totalExpenditure);
response.put("operatingProfit", totalIncome.subtract(totalExpenditure));
response.put("unmetRequirements", figures.unmet());
response.put("computable", figures.anyConfigured());
return gson.toJson(response);
}
// ─────────────────────────── shared response scaffolding ───────────────────────────
/**
* The General Information block every OSS return carries, built from the institution's own
* particulars so OSS 1 and OSS 2 can never state a different bank from one another.
*/
private Map<String, Object> baseResponse(final String returnCode, final String returnName, final Quarter quarter,
final Long officeId) {
final Map<String, Object> response = new LinkedHashMap<>();
response.put("returnCode", returnCode);
response.put("returnName", returnName);
response.put("reportingFrequency", "Quarterly");
final Map<String, Object> profile = institutionProfile();
final List<Map<String, Object>> generalInfo = new ArrayList<>();
generalInfo.add(info("Return Name", returnName));
generalInfo.add(info("Return Code", returnCode));
generalInfo.add(info("Bank Name", text(profile.get("bank_name"))));
generalInfo.add(info("Bank Code", text(profile.get("bank_code"))));
generalInfo.add(info("RO Name", text(profile.get("city"))));
generalInfo.add(info("Institution Type", text(profile.get("bank_type"))));
generalInfo.add(info("Reporting Frequency", "Quarterly"));
generalInfo.add(info("Start Date", quarter.start.format(RBI_DATE)));
generalInfo.add(info("Quarter End Date", quarter.end.format(RBI_DATE)));
response.put("generalInfo", generalInfo);
response.put("quarter", Map.of("label", quarter.label, "startDate", quarter.start.toString(), "endDate",
quarter.end.toString()));
response.put("officeId", officeId);
response.put("officeName", officeName(officeId));
response.put("signatories", signatories(profile, quarter));
return response;
}
/**
* The institution's particulars. Absent columns are tolerated rather than fatal: the return
* names what is missing on the General Information sheet, which is more useful than refusing to
* compute figures that are themselves correct.
*/
private Map<String, Object> institutionProfile() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT bank_name, bank_code, bank_type, city, state,
authorized_person_name, authorized_person_designation, authorized_person_email,
contact_phone
FROM m_institution_profile WHERE id = 1
""");
return rows.isEmpty() ? Map.of() : rows.get(0);
}
/**
* The signatory block, taken from the institution's authorised person. Dated to the quarter
* end rather than to today, so a return reprinted later still reads as filed for its own
* period.
*/
private List<Map<String, Object>> signatories(final Map<String, Object> profile, final Quarter quarter) {
final Map<String, Object> signatory = new LinkedHashMap<>();
signatory.put("heading", "Authorised Reporting Official");
signatory.put("name", text(profile.get("authorized_person_name")));
signatory.put("designation", text(profile.get("authorized_person_designation")));
signatory.put("email", text(profile.get("authorized_person_email")));
signatory.put("telephoneOffice", text(profile.get("contact_phone")));
signatory.put("place", text(profile.get("city")));
signatory.put("date", quarter.end.format(RBI_DATE));
return List.of(signatory);
}
private String officeName(final Long officeId) {
if (officeId == null) {
return "All offices (consolidated)";
}
final List<String> names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class,
officeId);
return names.isEmpty() ? "Office " + officeId : names.get(0);
}
private static Map<String, Object> info(final String label, final String value) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
// An unset particular is reported as such rather than as an empty cell, so a reviewer can
// see that it needs maintaining in the institution profile.
out.put("value", value.isBlank() ? "Not set in Institution Profile" : value);
out.put("configured", !value.isBlank());
return out;
}
private static String text(final Object value) {
return value == null ? "" : value.toString().trim();
}
// ─────────────────────────── the quarter ───────────────────────────
/** The reporting quarter a date falls in. RBI quarters run Apr-Jun, Jul-Sep, Oct-Dec, Jan-Mar. */
private static final class Quarter {
final LocalDate start;
final LocalDate end;
final String label;
private Quarter(final LocalDate start, final LocalDate end, final String label) {
this.start = start;
this.end = end;
this.label = label;
}
static Quarter containing(final LocalDate date) {
final int month = date.getMonthValue();
final int startMonth = ((month - 1) / 3) * 3 + 1;
final LocalDate start = date.withMonth(startMonth).withDayOfMonth(1);
final LocalDate end = start.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth());
return new Quarter(start, end, "Q" + ((startMonth - 1) / 3 + 1) + " " + start.getYear());
}
}
// ─────────────────────────── figures from the ledger ───────────────────────────
/**
* Resolves return lines to ledger figures, remembering which roles were unmapped or partly
* unusable so the return can report them once at the end.
*/
private final class RoleFigures {
private final Quarter quarter;
private final Long officeId;
private final List<String> reasons = new ArrayList<>();
private boolean anyConfigured;
RoleFigures(final Quarter quarter, final Long officeId) {
this.quarter = quarter;
this.officeId = officeId;
}
/** Closing balance as at the quarter end - for the balance-sheet lines of OSS 1. */
RoleFigure closing(final String roleKey) {
return figureFor(roleKey, null, quarter.end, officeId, this);
}
/** Movement over the quarter - for the earnings lines of OSS 2. */
RoleFigure movement(final String roleKey) {
return figureFor(roleKey, quarter.start, quarter.end, officeId, this);
}
/**
* Named by the line as the return prints it rather than by the role key: the reader is
* looking at a return, and "Investments" is what they will look for on the configuration
* screen, not OSS1_INVESTMENTS.
*/
void noteUnmapped(final String roleKey) {
reasons.add(lineName(roleKey) + " — no GL accounts mapped, so this line reports nothing.");
}
void noteExcluded(final String roleKey, final List<String> excluded) {
reasons.add(lineName(roleKey) + " — leaves out " + String.join(", ", excluded)
+ ", mapped but no longer postable. The figure shown is short by them.");
}
void noteConfigured() {
anyConfigured = true;
}
boolean anyConfigured() {
return anyConfigured;
}
List<String> unmet() {
return reasons;
}
}
/** A role's figure, plus whether it was configured at all - zero and unset are different. */
private static final class RoleFigure {
final String roleKey;
boolean configured;
BigDecimal amount = BigDecimal.ZERO;
final List<Map<String, Object>> accounts = new ArrayList<>();
final List<String> excluded = new ArrayList<>();
RoleFigure(final String roleKey) {
this.roleKey = roleKey;
}
}
/**
* Sums the GL accounts pinned to a role.
*
* With {@code fromDate} null the figure is the closing balance as at {@code toDate}; with a
* from-date it is the movement between the two, which is what an earnings line reports.
*
* Every pinned account is read back, usable or not. Filtering the unusable ones out in SQL
* would shrink the figure with nothing on the return to say an account had dropped out, so
* they are carried through and named instead.
*/
private RoleFigure figureFor(final String roleKey, final LocalDate fromDate, final LocalDate toDate,
final Long officeId, final RoleFigures owner) {
final RoleFigure figure = new RoleFigure(roleKey);
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage
FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
WHERE c.config_key = ?
ORDER BY a.gl_code
""", roleKey);
if (accounts.isEmpty()) {
owner.noteUnmapped(roleKey);
return figure;
}
figure.configured = true;
owner.noteConfigured();
for (final Map<String, Object> account : accounts) {
final Long accountId = ((Number) account.get("id")).longValue();
final int classification = ((Number) account.get("classification_enum")).intValue();
final String glCode = text(account.get("gl_code"));
// A pinned account can stop being usable after it was mapped: disabled, or turned into
// a header whose balance is the sum of its children and would double-count them.
final boolean disabled = Boolean.TRUE.equals(account.get("disabled"));
final boolean header = account.get("account_usage") != null
&& ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT;
if (disabled || header) {
figure.excluded
.add(glCode + " — " + text(account.get("name")) + (disabled ? " (disabled)" : " (header)"));
final Map<String, Object> skipped = new LinkedHashMap<>();
skipped.put("glAccountId", accountId);
skipped.put("glCode", glCode);
skipped.put("name", text(account.get("name")));
skipped.put("balance", null);
skipped.put("excluded", disabled ? "disabled" : "header");
figure.accounts.add(skipped);
continue;
}
final BigDecimal balance = balanceOf(accountId, classification, fromDate, toDate, officeId);
figure.amount = figure.amount.add(balance);
final Map<String, Object> detail = new LinkedHashMap<>();
detail.put("glAccountId", accountId);
detail.put("glCode", glCode);
detail.put("name", text(account.get("name")));
detail.put("balance", scaled(balance));
figure.accounts.add(detail);
}
if (!figure.excluded.isEmpty()) {
owner.noteExcluded(roleKey, figure.excluded);
}
return figure;
}
/**
* One account's ledger figure, on its own natural side so a liability reports positive rather
* than as a negative asset.
*
* Reversed entries and the reversal entries that cancel them are both excluded, so a reversal
* nets to nothing rather than to twice the original.
*/
private BigDecimal balanceOf(final Long accountId, final int classification, final LocalDate fromDate,
final LocalDate toDate, final Long officeId) {
final StringBuilder sql = new StringBuilder("""
SELECT 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 account_id = ? AND 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)
""");
final List<Object> args = new ArrayList<>();
args.add(accountId);
args.add(java.sql.Date.valueOf(toDate));
if (fromDate != null) {
sql.append(" AND entry_date >= ?");
args.add(java.sql.Date.valueOf(fromDate));
}
if (officeId != null) {
sql.append(" AND office_id = ?");
args.add(officeId);
}
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
final BigDecimal debit = toDecimal(row.get("total_debit"));
final BigDecimal credit = toDecimal(row.get("total_credit"));
final boolean debitNatured = classification == ASSET || classification == EXPENSE;
return debitNatured ? debit.subtract(credit) : credit.subtract(debit);
}
/**
* A role's name as the return prints it, for the caveats. Falls back to the key so a role
* added without a name here degrades to something readable rather than to nothing.
*/
private static String lineName(final String roleKey) {
return LINE_NAMES.getOrDefault(roleKey, roleKey);
}
private static final Map<String, String> LINE_NAMES = new LinkedHashMap<>();
static {
LINE_NAMES.put("OSS1_PAID_UP_CAPITAL", "Paid-up Capital");
LINE_NAMES.put("OSS1_RESERVES", "Reserve Fund and other Reserves");
LINE_NAMES.put("OSS1_PROFIT_LOSS", "Profit / Loss");
LINE_NAMES.put("OSS1_SUBORDINATED_DEBT", "Subordinated Debt (Tier II Capital)");
LINE_NAMES.put("OSS1_DEPOSITS", "Deposits and other Accounts");
LINE_NAMES.put("OSS1_DEPOSITS_TERM", "Term Deposits");
LINE_NAMES.put("OSS1_DEPOSITS_SAVINGS", "Savings Bank Deposits");
LINE_NAMES.put("OSS1_DEPOSITS_CURRENT", "Current Deposits");
LINE_NAMES.put("OSS1_BORROWINGS", "Borrowings");
LINE_NAMES.put("OSS1_OTHER_LIABILITIES", "Other Liabilities and Provisions");
LINE_NAMES.put("OSS1_CASH_IN_HAND", "Cash in Hand");
LINE_NAMES.put("OSS1_BALANCE_WITH_BANKS", "Balances with Banks");
LINE_NAMES.put("OSS1_INVESTMENTS", "Investments");
LINE_NAMES.put("OSS1_LOANS_ADVANCES", "Loans and Advances");
LINE_NAMES.put("OSS1_FIXED_ASSETS", "Fixed Assets");
LINE_NAMES.put("OSS1_OTHER_ASSETS", "Other Assets");
LINE_NAMES.put("OSS2_INTEREST_ON_ADVANCES", "Interest on loans and advances");
LINE_NAMES.put("OSS2_INTEREST_ON_MARKET_LENDING", "Interest on market lending");
LINE_NAMES.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest on investments");
LINE_NAMES.put("OSS2_INTEREST_ON_BANK_BALANCES", "Interest on bank balances");
LINE_NAMES.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "Commission, Exchange and Brokerage");
LINE_NAMES.put("OSS2_PROFIT_ON_INVESTMENTS", "Profit on sale of investments");
LINE_NAMES.put("OSS2_OTHER_INCOME", "Other income");
LINE_NAMES.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid on deposits");
LINE_NAMES.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings");
LINE_NAMES.put("OSS2_STAFF_COST", "Salaries, allowances and provident fund");
LINE_NAMES.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting");
LINE_NAMES.put("OSS2_DEPRECIATION", "Depreciation");
LINE_NAMES.put("OSS2_PROVISIONS", "Provisions and contingencies");
LINE_NAMES.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses");
}
/** JDBC returns SUM() as whichever numeric type the driver picks; normalise it. */
private static BigDecimal toDecimal(final Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString());
}
/** Rupees to the thousands the return is denominated in. */
private static BigDecimal scaled(final BigDecimal rupees) {
return rupees.divide(RETURN_SCALE, 2, RoundingMode.HALF_UP);
}
// ─────────────────────────── headcounts ───────────────────────────
/**
* Depositors as at a date - distinct clients holding a deposit account open on that date.
* Counted per client rather than per account, because a customer with three accounts is one
* depositor on the return.
*
* An account is open on the date if it was active by then and had not yet closed. Reading it
* this way, rather than by today's status, is what lets an earlier quarter be reprinted and
* still report the headcount that was true then.
*/
private Long countDepositors(final Long officeId, final LocalDate asOn, final List<Integer> depositTypes) {
final StringBuilder sql = new StringBuilder("""
SELECT COUNT(DISTINCT sa.client_id)
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
WHERE sa.status_enum = ?
AND sa.activatedon_date IS NOT NULL
AND sa.activatedon_date <= ?
AND (sa.closedon_date IS NULL OR sa.closedon_date > ?)
""");
final List<Object> args = new ArrayList<>();
args.add(SAVINGS_ACTIVE);
args.add(java.sql.Date.valueOf(asOn));
args.add(java.sql.Date.valueOf(asOn));
if (depositTypes != null && !depositTypes.isEmpty()) {
sql.append(" AND sa.deposit_type_enum IN (")
.append(String.join(", ", depositTypes.stream().map(t -> "?").toList())).append(")");
args.addAll(depositTypes);
}
if (officeId != null) {
sql.append(" AND c.office_id = ?");
args.add(officeId);
}
final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
return count == null ? 0L : count;
}
/**
* The financial year's result up to the quarter end: income earned less expenses incurred.
*
* Read across every income and expense account rather than the mapped ones, because this line
* exists to close the gap between the balance sheet and the books. A figure limited to mapped
* accounts would leave the return short by whatever was not mapped, which is the very problem
* the line is here to solve.
*
* The year starts on 1 April, as the Indian financial year does.
*/
private BigDecimal currentYearResult(final Quarter quarter, final Long officeId) {
final LocalDate yearStart = financialYearStart(quarter.end);
final StringBuilder sql = new StringBuilder("""
SELECT COALESCE(SUM(CASE WHEN a.classification_enum = ?
THEN CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END
ELSE CASE WHEN je.type_enum = 2 THEN -je.amount ELSE je.amount END
END), 0) AS result
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.classification_enum IN (?, ?)
AND je.entry_date >= ? AND je.entry_date <= ?
AND (je.reversed IS NULL OR je.reversed = false)
AND je.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""");
final List<Object> args = new ArrayList<>();
args.add(INCOME);
args.add(INCOME);
args.add(EXPENSE);
args.add(java.sql.Date.valueOf(yearStart));
args.add(java.sql.Date.valueOf(quarter.end));
if (officeId != null) {
sql.append(" AND je.office_id = ?");
args.add(officeId);
}
final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray());
return result == null ? BigDecimal.ZERO : result;
}
/** 1 April of the financial year a date falls in. */
private static LocalDate financialYearStart(final LocalDate date) {
final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return LocalDate.of(year, 4, 1);
}
/** Borrowers as at a date - distinct clients with a loan outstanding on that date. */
private Long borrowerCount(final Long officeId, final LocalDate asOn) {
final StringBuilder sql = new StringBuilder("""
SELECT COUNT(DISTINCT l.client_id)
FROM m_loan l
JOIN m_client c ON c.id = l.client_id
WHERE l.loan_status_id = ?
AND l.disbursedon_date IS NOT NULL
AND l.disbursedon_date <= ?
""");
final List<Object> args = new ArrayList<>();
args.add(LOAN_ACTIVE);
args.add(java.sql.Date.valueOf(asOn));
if (officeId != null) {
sql.append(" AND c.office_id = ?");
args.add(officeId);
}
final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
return count == null ? 0L : count;
}
// ─────────────────────────── line builders ───────────────────────────
private Map<String, Object> balanceLine(final String label, final String roleKey, final RoleFigures figures) {
return line(label, figures.closing(roleKey));
}
private Map<String, Object> movementLine(final String label, final String roleKey, final RoleFigures figures) {
return line(label, figures.movement(roleKey));
}
private static Map<String, Object> line(final String label, final RoleFigure figure) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", figure.roleKey);
out.put("configured", figure.configured);
// Null rather than zero when unmapped: the screen must be able to tell "nothing mapped"
// from "mapped and genuinely nil".
out.put("amount", figure.configured ? scaled(figure.amount) : null);
out.put("accounts", figure.accounts);
out.put("subtotal", false);
return out;
}
/**
* A figure the return computes for itself rather than reading from mapped accounts.
*
* It carries a role key so the subtotals can name it like any other component, but that key is
* not a configurable role: nothing can leave it unmapped, and nothing can map it wrong.
*/
private static Map<String, Object> derivedLine(final String label, final String roleKey,
final BigDecimal rupees) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", roleKey);
out.put("configured", true);
out.put("amount", scaled(rupees));
out.put("accounts", List.of());
out.put("derived", true);
out.put("subtotal", false);
return out;
}
private static Map<String, Object> countLine(final String label, final Long count) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", null);
out.put("configured", true);
out.put("amount", count);
out.put("accounts", List.of());
out.put("count", true);
out.put("subtotal", false);
return out;
}
/**
* A derived row, summed from the roles it names. Subtotals are never mapped to accounts of
* their own - a total and its components mapped separately would disagree the moment one side
* changed - and they name their components explicitly rather than summing whatever happens to
* sit above them, so inserting a line cannot silently change a total.
*/
private static Map<String, Object> subtotal(final String label, final List<Map<String, Object>> section,
final String... ofRoles) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", null);
out.put("configured", true);
out.put("amount", sumOfRoles(section, ofRoles));
out.put("accounts", List.of());
out.put("components", List.of(ofRoles));
out.put("subtotal", true);
return out;
}
/** The amount already computed for a labelled row, so a total is never derived twice. */
private static BigDecimal amountOf(final List<Map<String, Object>> section, final String label) {
for (final Map<String, Object> line : section) {
if (label.equals(line.get("label")) && line.get("amount") instanceof BigDecimal decimal) {
return decimal;
}
}
return BigDecimal.ZERO;
}
/**
* Sums the named roles out of a section.
*
* A component that is unmapped contributes nothing, which is why the return still reports
* {@code unmetRequirements}: the total is arithmetically right for what is mapped, and short by
* whatever is not.
*/
private static BigDecimal sumOfRoles(final List<Map<String, Object>> section, final String... roles) {
final Set<String> wanted = new LinkedHashSet<>(Arrays.asList(roles));
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> line : section) {
// Subtotals and headcounts carry no role of their own, so their key is null. Matched by
// the role rather than by a null-tolerant lookup, so a derived row can never be summed
// into the total that derives it.
final Object roleKey = line.get("roleKey");
if (roleKey == null || !wanted.contains(roleKey.toString())) {
continue;
}
if (line.get("amount") instanceof BigDecimal decimal) {
total = total.add(decimal);
}
}
return total;
}
private static LocalDate parseOrBusinessDate(final String param) {
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
}
}
@@ -0,0 +1,305 @@
/**
* 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.rbireserve.api;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
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.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.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;
/**
* Saved Form A / Form 9 returns: keeping a generated return so it can be read again later exactly as
* it was generated.
*
* A saved return is served from its stored snapshot, never recomputed. Recomputing would quietly
* produce a different statement from the one that was reviewed or filed - a backdated journal entry,
* a changed GL mapping or a corrected rate all move figures that were already reported - and a
* report whose figures change after it was filed is worse than no report at all.
*
* Deliberately not maker-checker: saving a snapshot changes no balance and posts no entry. The
* filing lifecycle (draft, checked, approved, submitted) is what carries the approval, and that is
* recorded on the row's status rather than in a separate command queue.
*/
@Path("/v1/rbiformafiling")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class RbiFormAFilingApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIFORMAFILING";
/**
* The filing lifecycle RBI's own timetable implies: a return is drafted, checked and approved
* inside the bank, sent provisionally within a week of the fortnight ending and finally within
* twenty days, and revised if a figure turns out wrong after that.
*/
private static final List<String> STATUSES = List.of("DRAFT", "GENERATED", "CHECKED", "APPROVED",
"PROVISIONAL_SUBMITTED", "FINAL_SUBMITTED", "REVISED");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
/** Saved returns, newest first. The stored snapshot is left out - the list does not need it. */
@GET
public String retrieveAll(@QueryParam("formCode") final String formCode) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final StringBuilder sql = new StringBuilder("""
SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end,
f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained,
f.rate_percentage, f.remarks, f.submitted_on, f.created_on, u.username AS created_by_name
FROM m_rbi_form_a_filing f
LEFT JOIN m_appuser u ON u.id = f.created_by
""");
final List<Object> args = new ArrayList<>();
if (formCode != null && !formCode.isBlank()) {
sql.append(" WHERE f.form_code = ?");
args.add(formCode);
}
sql.append(" ORDER BY f.reporting_date DESC, f.id DESC");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toSummary(row));
}
return gson.toJson(out);
}
/** One saved return, with the snapshot, so the screen can render exactly what was saved. */
@GET
@Path("{filingId}")
public String retrieveOne(@PathParam("filingId") final Long filingId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end,
f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained,
f.rate_percentage, f.remarks, f.submitted_on, f.created_on, f.report_json,
u.username AS created_by_name
FROM m_rbi_form_a_filing f
LEFT JOIN m_appuser u ON u.id = f.created_by
WHERE f.id = ?
""", filingId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
"Saved return " + filingId + " was not found.");
}
final Map<String, Object> row = rows.get(0);
final Map<String, Object> out = toSummary(row);
// The snapshot is embedded as JSON rather than as a string, so the screen receives the same
// shape the live endpoint returns and can render it through the same code path.
final String json = (String) row.get("report_json");
out.put("report", json == null || json.isBlank() ? null : gson.fromJson(json, JsonObject.class));
return gson.toJson(out);
}
/**
* Saves a generated return. Re-saving the same form for the same reporting date replaces that
* snapshot: regenerating a return for a date already saved is a correction of it, not a second
* statement for the same day.
*/
@POST
@Consumes({ MediaType.APPLICATION_JSON })
public String create(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String formCode = this.fromJsonHelper.extractStringNamed("formCode", root);
final LocalDate reportingDate = this.fromJsonHelper.extractLocalDateNamed("reportingDate", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
final Long officeId = this.fromJsonHelper.extractLongNamed("officeId", root);
if (formCode == null || formCode.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.form.code",
"The form being saved must be named.");
}
if (reportingDate == null) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.reporting.date",
"A reporting date is required.");
}
// The return itself, as the screen received it. Stored verbatim: the point of a saved return
// is that it does not change afterwards, so nothing here re-derives a figure from it.
final JsonObject report = root.getAsJsonObject().getAsJsonObject("report");
if (report == null) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.report",
"The generated return must be supplied with the save.");
}
final LocalDate periodStart = localDate(report, "fortnightStart");
final LocalDate periodEnd = localDate(report, "fortnightEnd");
final BigDecimal ndtl = decimal(report, "ndtlAmount");
final BigDecimal required = decimal(report, "requiredAmount");
final BigDecimal maintained = decimal(report, "maintainedAmount");
final BigDecimal rate = decimal(report, "ratePercentage");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
}
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class,
formCode, java.sql.Date.valueOf(reportingDate));
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_rbi_form_a_filing
SET report_json = ?, ndtl_amount = ?, required_amount = ?, maintained_amount = ?,
average_maintained = ?, rate_percentage = ?, remarks = ?, office_id = ?,
period_start = ?, period_end = ?, fortnight_end = ?,
lastmodified_on = now(), lastmodified_by = ?
WHERE form_code = ? AND reporting_date = ?
""", gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId,
sqlDate(periodStart), sqlDate(periodEnd), sqlDate(periodEnd == null ? reportingDate : periodEnd),
this.context.authenticatedUser().getId(), formCode, java.sql.Date.valueOf(reportingDate));
} else {
jdbcTemplate.update("""
INSERT INTO m_rbi_form_a_filing
(form_code, reporting_date, fortnight_end, period_start, period_end, status,
report_json, ndtl_amount, required_amount, maintained_amount, average_maintained,
rate_percentage, remarks, office_id, created_on, created_by)
VALUES (?, ?, ?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?)
""", formCode, java.sql.Date.valueOf(reportingDate),
sqlDate(periodEnd == null ? reportingDate : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId,
this.context.authenticatedUser().getId());
}
return retrieveAll(formCode);
}
/**
* Moves a saved return along its filing lifecycle. The snapshot is untouched: a return that has
* been checked and submitted must still show the figures it was checked on.
*/
@POST
@Path("{filingId}/status")
@Consumes({ MediaType.APPLICATION_JSON })
public String updateStatus(@PathParam("filingId") final Long filingId, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String status = this.fromJsonHelper.extractStringNamed("status", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
if (status == null || !STATUSES.contains(status)) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.unknown.status",
"Status must be one of " + STATUSES + ".");
}
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE id = ?",
Integer.class, filingId);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
"Saved return " + filingId + " was not found.");
}
// The submission date is stamped when a return is actually sent, and only then - an earlier
// stage has nothing to date, and re-stamping on a later stage would lose when it was sent.
final boolean submitted = "PROVISIONAL_SUBMITTED".equals(status) || "FINAL_SUBMITTED".equals(status);
jdbcTemplate.update("""
UPDATE m_rbi_form_a_filing
SET status = ?,
remarks = COALESCE(?, remarks),
submitted_on = CASE WHEN ? THEN COALESCE(submitted_on, CURRENT_DATE) ELSE submitted_on END,
lastmodified_on = now(), lastmodified_by = ?
WHERE id = ?
""", status, remarks, submitted, this.context.authenticatedUser().getId(), filingId);
return retrieveAll(null);
}
/** Discards a saved return. Only ever removes the snapshot; nothing in the ledger is touched. */
@DELETE
@Path("{filingId}")
public String delete(@PathParam("filingId") final Long filingId) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final Integer removed = jdbcTemplate.update("DELETE FROM m_rbi_form_a_filing WHERE id = ?", filingId);
if (removed == 0) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
"Saved return " + filingId + " was not found.");
}
return retrieveAll(null);
}
private Map<String, Object> toSummary(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("id", row.get("id"));
out.put("formCode", row.get("form_code"));
out.put("reportingDate", str(row.get("reporting_date")));
out.put("periodStart", str(row.get("period_start")));
out.put("periodEnd", str(row.get("period_end")));
out.put("status", row.get("status"));
out.put("ndtlAmount", row.get("ndtl_amount"));
out.put("requiredAmount", row.get("required_amount"));
out.put("maintainedAmount", row.get("maintained_amount"));
out.put("averageMaintained", row.get("average_maintained"));
out.put("ratePercentage", row.get("rate_percentage"));
out.put("remarks", row.get("remarks"));
out.put("submittedOn", str(row.get("submitted_on")));
out.put("createdOn", str(row.get("created_on")));
out.put("createdBy", row.get("created_by_name"));
return out;
}
private static String str(final Object value) {
return value == null ? null : String.valueOf(value);
}
private static java.sql.Date sqlDate(final LocalDate date) {
return date == null ? null : java.sql.Date.valueOf(date);
}
private static BigDecimal decimal(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
return object.get(field).getAsBigDecimal();
}
private static LocalDate localDate(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
final String value = object.get(field).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
}
@@ -0,0 +1,692 @@
/**
* 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.rbireserve.api;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.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.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 RBI Form 1 (CRR) and Form 9 (SLR) configuration - the CRR/SLR
* percentages and the GL accounts the returns read their figures from.
*
* Both halves live on one screen because they are one decision: a percentage is meaningless without
* knowing which accounts it applies to, and an administrator who sets one and forgets the other
* produces a return that looks complete and is wrong.
*
* Roles are fixed (see {@link #ROLE_DESCRIPTIONS}); accounts are pinned to them. Unlike
* m_clearing_gl_config, a role takes MANY accounts - a bank holds current accounts at a dozen other
* banks and each counts toward the balances it maintains.
*/
@Path("/v1/rbireserveconfig")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class RbiReserveConfigApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG";
/**
* The roles a return can read, in the order the screen presents them. Held here rather than
* seeded as rows because they are structural - a role only exists if code computes something
* from it, so adding one is a code change either way.
*/
private static final Map<String, String> ROLE_DESCRIPTIONS = new LinkedHashMap<>();
static {
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
+ "Pin every such account - a bank typically banks with several.");
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
+ "Pin the deposit accounts and any interest accrued on them.");
ROLE_DESCRIPTIONS.put("SLR_INVESTMENTS",
"SLR-eligible investments - central and state government securities. "
+ "Counts toward SLR maintained on Form 9. Exclude anything marked non-SLR.");
// OSS 1 - Return on Assets and Liabilities. The balance-sheet heads, each read as its
// closing balance at the quarter end.
ROLE_DESCRIPTIONS.put("OSS1_PAID_UP_CAPITAL",
"Share capital paid up by members. OSS 1 liability head 1.");
ROLE_DESCRIPTIONS.put("OSS1_RESERVES",
"Reserve fund and other reserves - statutory, building, dividend equalisation, "
+ "provisions for standard assets. OSS 1 liability head 2.");
ROLE_DESCRIPTIONS.put("OSS1_PROFIT_LOSS",
"Accumulated profit or loss carried on the balance sheet. OSS 1 liability head 3.");
ROLE_DESCRIPTIONS.put("OSS1_SUBORDINATED_DEBT",
"Subordinated debt counted as Tier II capital. OSS 1 liability head 5.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS",
"All deposit liabilities. OSS 1 liability head 6. Pin the deposit control accounts; "
+ "the term/savings/current heads below break this same total down and are not added to it.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_TERM",
"Term deposits, including recurring deposits and matured term deposits. "
+ "A breakdown of OSS1_DEPOSITS, not an addition to it.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_SAVINGS",
"Savings bank deposits. A breakdown of OSS1_DEPOSITS, not an addition to it.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_CURRENT",
"Current account deposits. A breakdown of OSS1_DEPOSITS, not an addition to it.");
ROLE_DESCRIPTIONS.put("OSS1_BORROWINGS",
"Borrowings from banks and other institutions. OSS 1 liability head 7.");
ROLE_DESCRIPTIONS.put("OSS1_OTHER_LIABILITIES",
"Other liabilities and provisions - bills payable, interest accrued, sundry creditors.");
ROLE_DESCRIPTIONS.put("OSS1_CASH_IN_HAND",
"Cash in hand on the asset side of OSS 1. Usually the same accounts as CASH_IN_HAND.");
ROLE_DESCRIPTIONS.put("OSS1_BALANCE_WITH_BANKS",
"Balances with banks - current, savings and fixed. "
+ "Usually the same accounts as BALANCE_WITH_OTHER_BANKS, plus any fixed placements.");
ROLE_DESCRIPTIONS.put("OSS1_INVESTMENTS",
"All investments, SLR and non-SLR together. OSS 1 asset head 3.");
ROLE_DESCRIPTIONS.put("OSS1_LOANS_ADVANCES",
"Loans and advances to members and others. OSS 1 asset head 4.");
ROLE_DESCRIPTIONS.put("OSS1_FIXED_ASSETS",
"Premises, furniture and other fixed assets, net of depreciation.");
ROLE_DESCRIPTIONS.put("OSS1_OTHER_ASSETS", "Other assets - interest receivable, sundry debtors, stationery.");
// OSS 2 - Return on Earnings. These read the movement over the quarter, not a balance: an
// earnings line reports what was earned or spent in the period.
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_ADVANCES",
"Interest and discount earned on loans and advances. OSS 2 item 2.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_MARKET_LENDING",
"Interest on lending to banks - call and short notice money. OSS 2 item 3.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest earned on investments. OSS 2 item 4.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_BANK_BALANCES",
"Interest on balances with RBI, inter-bank deposits and placements. OSS 2 item 5.");
ROLE_DESCRIPTIONS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE",
"Commission, exchange and brokerage earned. OSS 2 item 6.");
ROLE_DESCRIPTIONS.put("OSS2_PROFIT_ON_INVESTMENTS",
"Profit on sale of investments. Pin the income account only; losses belong under provisions.");
ROLE_DESCRIPTIONS.put("OSS2_OTHER_INCOME", "Other income not covered by the heads above.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid or accrued on deposits. OSS 2 item 10.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings. OSS 2 item 11.");
ROLE_DESCRIPTIONS.put("OSS2_STAFF_COST",
"Salaries, allowances, provident fund and other staff cost. OSS 2 item 12.");
ROLE_DESCRIPTIONS.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting. OSS 2 item 13.");
ROLE_DESCRIPTIONS.put("OSS2_DEPRECIATION", "Depreciation on the bank's own property. OSS 2 item 14.");
ROLE_DESCRIPTIONS.put("OSS2_PROVISIONS",
"Provisions and contingencies, including bad debt provision and loss on investments.");
ROLE_DESCRIPTIONS.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses. OSS 2 item 16.");
}
/**
* A readable name per role. Held next to the descriptions rather than in the screen, so a role
* added here cannot show up in the UI as a raw key nobody wrote a label for.
*/
private static final Map<String, String> ROLE_LABELS = new LinkedHashMap<>();
static {
ROLE_LABELS.put("CASH_IN_HAND", "Cash in hand");
ROLE_LABELS.put("BALANCE_WITH_OTHER_BANKS", "Balances with other banks");
ROLE_LABELS.put("DEMAND_TIME_LIABILITIES", "Demand and Time Liabilities (DTL)");
ROLE_LABELS.put("SLR_INVESTMENTS", "SLR-eligible investments");
ROLE_LABELS.put("OSS1_PAID_UP_CAPITAL", "1. Paid-up Capital");
ROLE_LABELS.put("OSS1_RESERVES", "2. Reserve Fund and other Reserves");
ROLE_LABELS.put("OSS1_PROFIT_LOSS", "3. Profit / Loss");
ROLE_LABELS.put("OSS1_SUBORDINATED_DEBT", "5. Subordinated Debt (Tier II)");
ROLE_LABELS.put("OSS1_DEPOSITS", "6. Deposits and other Accounts");
ROLE_LABELS.put("OSS1_DEPOSITS_TERM", "6 i. Term Deposits");
ROLE_LABELS.put("OSS1_DEPOSITS_SAVINGS", "6 ii. Savings Bank Deposits");
ROLE_LABELS.put("OSS1_DEPOSITS_CURRENT", "6 iii. Current Deposits");
ROLE_LABELS.put("OSS1_BORROWINGS", "7. Borrowings");
ROLE_LABELS.put("OSS1_OTHER_LIABILITIES", "8. Other Liabilities and Provisions");
ROLE_LABELS.put("OSS1_CASH_IN_HAND", "Assets 1. Cash in Hand");
ROLE_LABELS.put("OSS1_BALANCE_WITH_BANKS", "Assets 2. Balances with Banks");
ROLE_LABELS.put("OSS1_INVESTMENTS", "Assets 3. Investments");
ROLE_LABELS.put("OSS1_LOANS_ADVANCES", "Assets 4. Loans and Advances");
ROLE_LABELS.put("OSS1_FIXED_ASSETS", "Assets 5. Fixed Assets");
ROLE_LABELS.put("OSS1_OTHER_ASSETS", "Assets 6. Other Assets");
ROLE_LABELS.put("OSS2_INTEREST_ON_ADVANCES", "2. Interest on loans and advances");
ROLE_LABELS.put("OSS2_INTEREST_ON_MARKET_LENDING", "3. Interest on market lending");
ROLE_LABELS.put("OSS2_INTEREST_ON_INVESTMENTS", "4. Interest on investments");
ROLE_LABELS.put("OSS2_INTEREST_ON_BANK_BALANCES", "5. Interest on bank balances");
ROLE_LABELS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "6. Commission, Exchange and Brokerage");
ROLE_LABELS.put("OSS2_PROFIT_ON_INVESTMENTS", "7. Profit on sale of investments");
ROLE_LABELS.put("OSS2_OTHER_INCOME", "8. Other income");
ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "10. Interest paid on deposits");
ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "11. Interest paid on borrowings");
ROLE_LABELS.put("OSS2_STAFF_COST", "12. Salaries, allowances and PF");
ROLE_LABELS.put("OSS2_RENT_TAXES_INSURANCE", "13. Rent, taxes, insurance and lighting");
ROLE_LABELS.put("OSS2_DEPRECIATION", "14. Depreciation");
ROLE_LABELS.put("OSS2_PROVISIONS", "15. Provisions and contingencies");
ROLE_LABELS.put("OSS2_OTHER_OPERATING_EXPENSES", "16. Other operating expenses");
}
/**
* Which return each role belongs to, and how it is used there, so the screen can separate a
* long list into the forms it affects and state the consequence of leaving one unmapped.
*/
private static final Map<String, String> ROLE_GROUPS = new LinkedHashMap<>();
private static final Map<String, String> ROLE_GROUP_LABELS = new LinkedHashMap<>();
private static final Map<String, String> ROLE_USAGE = new LinkedHashMap<>();
static {
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
if (role.startsWith("OSS1_")) {
ROLE_GROUPS.put(role, "OSS1");
ROLE_USAGE.put(role, "OSS 1 (RAL) — closing balance at quarter end");
} else if (role.startsWith("OSS2_")) {
ROLE_GROUPS.put(role, "OSS2");
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
} else {
ROLE_GROUPS.put(role, "RESERVE");
if ("SLR_INVESTMENTS".equals(role)) {
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
} else {
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
}
}
}
}
/**
* SAVINGS_DEMAND is the share of savings balances RBI treats as a demand liability. It sits
* beside CRR and SLR because it behaves like them: set by RBI, changed by circular, and
* effective-dated so a refiled return reproduces the split that was in force.
*/
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
/** The four treatments a head can be given, each naming a branch the returns already have. */
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
"MEMO");
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
static {
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head");
USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL");
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward CRR maintained (Form 1)");
// Separate from MAINTAINED because the two returns admit different assets: CRR is met in
// cash and balances, SLR in cash, gold and unencumbered approved securities. One usage for
// both would let a head meant for one return quietly satisfy the other.
USAGE_DESCRIPTIONS.put("SLR_ASSET", "Counted toward SLR maintained (Form 9)");
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
}
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
static {
BUILT_IN_USAGE.put("DEMAND_TIME_LIABILITIES", "LIABILITY");
BUILT_IN_USAGE.put("BALANCE_WITH_OTHER_BANKS", "MAINTAINED");
BUILT_IN_USAGE.put("CASH_IN_HAND", "MAINTAINED");
BUILT_IN_USAGE.put("SLR_INVESTMENTS", "MAINTAINED");
}
/** Roles whose accounts carry a Demand/Time class. */
private static final java.util.Set<String> LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES");
private static final String DEMAND = "DEMAND";
private static final String TIME = "TIME";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
// ─────────────────────────── read ───────────────────────────
@GET
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rates", rates());
out.put("glRoles", glRoles());
return gson.toJson(out);
}
@GET
@Path("template")
public String template() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
// Header accounts are excluded deliberately. A header carries no journal entry of its own -
// its balance is the sum of its children - so pinning both a header and any child would
// double-count that child. Only postable detail accounts are offered.
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT id, name, gl_code, classification_enum
FROM acc_gl_account
WHERE disabled = false AND account_usage = 1
ORDER BY gl_code
""");
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"));
o.put("classification", a.get("classification_enum"));
options.add(o);
}
return gson.toJson(Map.of("glAccountOptions", options, "rateTypes", RATE_TYPES));
}
private List<Map<String, Object>> rates() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, rate_type, percentage, effective_from, is_seed_default
FROM m_rbi_reserve_rate
ORDER BY rate_type, effective_from DESC
""");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", row.get("id"));
o.put("rateType", row.get("rate_type"));
o.put("percentage", row.get("percentage"));
o.put("effectiveFrom", String.valueOf(row.get("effective_from")));
// A placeholder shipped with the migration, not a figure this bank confirmed.
o.put("seedDefault", row.get("is_seed_default"));
out.add(o);
}
return out;
}
/** Every role, including those with nothing pinned - the screen must show the gaps. */
private List<Map<String, Object>> glRoles() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id, c.config_key, c.gl_account_id, c.weight_percentage, c.liability_class,
a.name AS gl_account_name, a.gl_code
FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
ORDER BY c.config_key, a.gl_code, c.liability_class
""");
// Seeded with every key the configuration knows, built in or added, so a mapping against a
// head an administrator created lands in a bucket rather than being dropped as unknown.
final Map<String, List<Map<String, Object>>> byKey = new LinkedHashMap<>();
for (final String key : knownRoleKeys()) {
byKey.put(key, new ArrayList<>());
}
for (final Map<String, Object> row : rows) {
final String key = (String) row.get("config_key");
// A row whose key is no longer a known role is ignored rather than surfaced - it cannot
// affect any figure, because nothing reads that key.
final List<Map<String, Object>> bucket = byKey.get(key);
if (bucket == null) {
continue;
}
final Map<String, Object> account = new LinkedHashMap<>();
account.put("id", row.get("id"));
account.put("glAccountId", row.get("gl_account_id"));
account.put("glAccountName", row.get("gl_account_name"));
account.put("glCode", row.get("gl_code"));
// The portion of the balance this mapping takes, and which head it falls under. Sent
// back so the screen edits what is stored rather than re-deriving a default that would
// overwrite a deliberate split on the next save.
account.put("weightPercentage", row.get("weight_percentage"));
account.put("liabilityClass", row.get("liability_class"));
bucket.add(account);
}
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map.Entry<String, String> role : ROLE_DESCRIPTIONS.entrySet()) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("configKey", role.getKey());
o.put("description", role.getValue());
o.put("label", ROLE_LABELS.getOrDefault(role.getKey(), role.getKey()));
// Which return reads this role, so the screen can group a long list by the form it
// affects rather than presenting every role as equally relevant to every return.
final String group = ROLE_GROUPS.get(role.getKey());
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.get(group));
o.put("usedBy", ROLE_USAGE.get(role.getKey()));
// Built-in roles are wired into the arithmetic by name, so they cannot be renamed or
// removed - the screen greys those controls rather than offering an action that would
// have to fail.
o.put("builtIn", true);
o.put("usage", BUILT_IN_USAGE.getOrDefault(role.getKey(), "MEMO"));
o.put("classified", LIABILITY_ROLES.contains(role.getKey()));
o.put("accounts", byKey.get(role.getKey()));
out.add(o);
}
// Heads an administrator added. Appended after the built-ins so the structural roles keep
// their fixed order at the top of each group regardless of what was added later.
for (final Map<String, Object> row : customRoleRows()) {
final String key = (String) row.get("config_key");
final String group = (String) row.get("role_group");
final String usage = (String) row.get("role_usage");
final Map<String, Object> o = new LinkedHashMap<>();
o.put("configKey", key);
o.put("description", row.get("description"));
o.put("label", row.get("label"));
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
o.put("builtIn", false);
o.put("usage", usage);
// A liability head carries the demand/time split; nothing else does.
o.put("classified", "LIABILITY".equals(usage));
o.put("roleId", row.get("id"));
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
out.add(o);
}
return out;
}
/** Administrator-defined heads, in the order the screen presents them. */
private List<Map<String, Object>> customRoleRows() {
return jdbcTemplate.queryForList("""
SELECT id, config_key, label, description, role_group, role_usage, position
FROM m_rbi_reserve_role
ORDER BY role_group, position, label
""");
}
/** Every role key the configuration currently knows - built in or added. */
private java.util.Set<String> knownRoleKeys() {
final java.util.Set<String> keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet());
for (final Map<String, Object> row : customRoleRows()) {
keys.add((String) row.get("config_key"));
}
return keys;
}
// ─────────────────────────── write ───────────────────────────
/**
* Replaces the whole set of accounts pinned to one role. Sending the full set rather than
* add/remove deltas keeps the screen and the stored configuration in step even if two
* administrators have the page open at once - the last save wins wholesale, instead of the two
* interleaving into a set neither of them chose.
*/
@PUT
@Path("gl/{configKey}")
@Consumes({ MediaType.APPLICATION_JSON })
public String updateGlRole(@PathParam("configKey") final String configKey, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
if (!knownRoleKeys().contains(configKey)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.role",
"'" + configKey + "' is not a known RBI reserve configuration role.");
}
final boolean classified = isClassifiedRole(configKey);
final JsonElement root = this.fromJsonHelper.parse(body);
// Two shapes are accepted. "mappings" carries a weight and a class per account and is what
// the screen sends; "glAccountIds" is the flat list the first version of this screen sent,
// still honoured so an older client keeps working - its accounts are taken whole, which is
// exactly what they meant before weights existed.
final List<Mapping> mappings = new ArrayList<>();
final com.google.gson.JsonArray rows = root.getAsJsonObject().has("mappings")
? root.getAsJsonObject().getAsJsonArray("mappings")
: null;
if (rows != null) {
for (final JsonElement element : rows) {
final com.google.gson.JsonObject row = element.getAsJsonObject();
final Long accountId = row.get("glAccountId").getAsLong();
final BigDecimal weight = row.has("weightPercentage") && !row.get("weightPercentage").isJsonNull()
? row.get("weightPercentage").getAsBigDecimal()
: BigDecimal.valueOf(100);
final String liabilityClass = classified && row.has("liabilityClass")
&& !row.get("liabilityClass").isJsonNull() ? row.get("liabilityClass").getAsString() : null;
mappings.add(new Mapping(accountId, weight, liabilityClass));
}
} else {
final String[] ids = this.fromJsonHelper.extractArrayNamed("glAccountIds", root);
if (ids != null) {
for (final String id : ids) {
mappings.add(new Mapping(Long.valueOf(id), BigDecimal.valueOf(100), null));
}
}
}
final java.util.Set<String> seen = new java.util.LinkedHashSet<>();
for (final Mapping mapping : mappings) {
final Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND disabled = false AND account_usage = 1",
Integer.class, mapping.accountId);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.gl.account.not.found",
"GL account " + mapping.accountId + " was not found, is disabled, or is a header account.");
}
// A weight outside 0-100 is not a split of a balance. Above 100 it would report more
// than the account holds; below zero it would subtract a liability from the base.
if (mapping.weight == null || mapping.weight.compareTo(BigDecimal.ZERO) < 0
|| mapping.weight.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.weight.out.of.range",
"The portion taken from an account must be between 0 and 100 percent.");
}
if (mapping.liabilityClass != null && !DEMAND.equals(mapping.liabilityClass)
&& !TIME.equals(mapping.liabilityClass)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.liability.class",
"A liability must be classified as " + DEMAND + " or " + TIME + ".");
}
// The same account may appear twice under one role only if the two rows are different
// heads - a demand slice and a time slice. Twice under the same head is a double count.
if (!seen.add(mapping.accountId + "/" + mapping.liabilityClass)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.duplicate.mapping",
"GL account " + mapping.accountId
+ " is mapped to this role twice under the same head; it would be counted twice.");
}
}
jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", configKey);
for (final Mapping mapping : mappings) {
jdbcTemplate.update("""
INSERT INTO m_rbi_reserve_gl_config
(config_key, gl_account_id, weight_percentage, liability_class, created_on)
VALUES (?, ?, ?, ?, now())
""", configKey, mapping.accountId, mapping.weight, mapping.liabilityClass);
}
return retrieveAll();
}
/** One account pinned to a role: which account, how much of it, and under which head. */
private record Mapping(Long accountId, BigDecimal weight, String liabilityClass) {}
/** Whether a role's accounts carry a Demand/Time class - built in or administrator-defined. */
private boolean isClassifiedRole(final String configKey) {
if (LIABILITY_ROLES.contains(configKey)) {
return true;
}
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT role_usage FROM m_rbi_reserve_role WHERE config_key = ?", configKey);
return !found.isEmpty() && "LIABILITY".equals(found.get(0).get("role_usage"));
}
/**
* Adds a head to a return - a reserve, a tax payable, a bills-payable account, anything the
* bank carries that the built-in roles do not name.
*
* The head needs no new code because it declares how it is consumed: a LIABILITY head enters the
* base under its demand or time class, an ASSET_NET head is netted off it, a MAINTAINED head
* counts toward the requirement, a MEMO head is shown and totals nothing. Each of those is a
* branch the returns already have.
*/
@POST
@Path("role")
@Consumes({ MediaType.APPLICATION_JSON })
public String createRole(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String label = this.fromJsonHelper.extractStringNamed("label", root);
final String description = this.fromJsonHelper.extractStringNamed("description", root);
final String group = this.fromJsonHelper.extractStringNamed("group", root);
final String usage = this.fromJsonHelper.extractStringNamed("usage", root);
if (label == null || label.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.no.label",
"A name is required for the head.");
}
if (group == null || !ROLE_GROUP_LABELS.containsKey(group)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.group",
"The head must belong to one of " + ROLE_GROUP_LABELS.keySet() + ".");
}
if (usage == null || !ROLE_USAGES.contains(usage)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.usage",
"How the head is used must be one of " + ROLE_USAGES + ".");
}
// The key is derived from the name rather than typed, so an administrator naming a head does
// not also have to invent a stable identifier - and cannot collide with a built-in role by
// choosing one of its names.
final String base = "CUSTOM_" + label.toUpperCase(java.util.Locale.ROOT).replaceAll("[^A-Z0-9]+", "_")
.replaceAll("^_|_$", "");
String key = base.length() > 50 ? base.substring(0, 50) : base;
int suffix = 2;
while (knownRoleKeys().contains(key)) {
final String candidate = base + "_" + suffix++;
key = candidate.length() > 50 ? candidate.substring(0, 50) : candidate;
}
jdbcTemplate.update("""
INSERT INTO m_rbi_reserve_role
(config_key, label, description, role_group, role_usage, position, created_on, created_by)
VALUES (?, ?, ?, ?, ?, ?, now(), ?)
""", key, label.trim(), description, group, usage, nextPosition(group),
this.context.authenticatedUser().getId());
return retrieveAll();
}
/** Where a new head sits in its group: after the ones already there. */
private int nextPosition(final String group) {
final Integer max = jdbcTemplate.queryForObject(
"SELECT COALESCE(MAX(position), 100) FROM m_rbi_reserve_role WHERE role_group = ?", Integer.class,
group);
return (max == null ? 100 : max) + 1;
}
/**
* Removes an administrator-defined head, together with the accounts pinned to it.
*
* Built-in roles are refused: the returns reference them by name, so a deleted one would not
* leave a smaller return but a broken computation.
*/
@DELETE
@Path("role/{roleId}")
public String deleteRole(@PathParam("roleId") final Long roleId) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT config_key, label FROM m_rbi_reserve_role WHERE id = ?", roleId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.not.found",
"Head " + roleId + " was not found, or is a built-in role that cannot be removed.");
}
final String key = (String) found.get(0).get("config_key");
// The mappings go with it. Left behind they would reference a head nothing reads, and would
// silently reappear under a later head that happened to derive the same key.
jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", key);
jdbcTemplate.update("DELETE FROM m_rbi_reserve_role WHERE id = ?", roleId);
return retrieveAll();
}
/**
* Sets a rate with effect from a date. Restating a rate for a date it already carries edits that
* row; a new date adds one. Earlier rows are never rewritten, so a return filed for a past
* fortnight keeps reporting the rate that was actually in force then.
*/
@POST
@Path("rate")
@Consumes({ MediaType.APPLICATION_JSON })
public String upsertRate(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String rateType = this.fromJsonHelper.extractStringNamed("rateType", root);
final BigDecimal percentage = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("percentage", root);
final LocalDate effectiveFrom = this.fromJsonHelper.extractLocalDateNamed("effectiveFrom", root);
if (rateType == null || !RATE_TYPES.contains(rateType)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.rate.type",
"Rate type must be one of " + RATE_TYPES + ".");
}
if (percentage == null || percentage.compareTo(BigDecimal.ZERO) < 0
|| percentage.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.out.of.range",
"Percentage must be between 0 and 100.");
}
if (effectiveFrom == null) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.no.effective.date",
"An effective-from date is required.");
}
final Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from = ?", Integer.class,
rateType, java.sql.Date.valueOf(effectiveFrom));
if (exists != null && exists > 0) {
// Saving over a seeded placeholder makes it this bank's own figure, so the flag clears
// and the returns stop captioning it as a default.
jdbcTemplate.update("""
UPDATE m_rbi_reserve_rate SET percentage = ?, is_seed_default = false
WHERE rate_type = ? AND effective_from = ?
""", percentage, rateType, java.sql.Date.valueOf(effectiveFrom));
} else {
jdbcTemplate.update("""
INSERT INTO m_rbi_reserve_rate (rate_type, percentage, effective_from, created_on, created_by,
is_seed_default)
VALUES (?, ?, ?, now(), ?, false)
""", rateType, percentage, java.sql.Date.valueOf(effectiveFrom),
this.context.authenticatedUser().getId());
}
return retrieveAll();
}
/**
* Removes one effective-dated rate row. The last remaining row for a type is kept: with none at
* all, every return of that type would stop computing, which is a harsher outcome than an
* administrator deleting the wrong row expects.
*/
@DELETE
@Path("rate/{rateId}")
public String deleteRate(@PathParam("rateId") final Long rateId) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT rate_type FROM m_rbi_reserve_rate WHERE id = ?", rateId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.not.found",
"Rate " + rateId + " was not found.");
}
final String rateType = (String) found.get(0).get("rate_type");
final Integer remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ?",
Integer.class, rateType);
if (remaining != null && remaining <= 1) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.last.row",
"The only " + rateType + " rate on record cannot be removed - edit it instead.");
}
jdbcTemplate.update("DELETE FROM m_rbi_reserve_rate WHERE id = ?", rateId);
return retrieveAll();
}
}
@@ -0,0 +1,938 @@
/**
* 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.rbireserve.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.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
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;
/**
* Live computation of RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly).
*
* <h3>What the returns say</h3>
*
* Both returns answer the same shape of question: against a liability base (Demand and Time
* Liabilities), a percentage set by RBI must be maintained in a defined class of assets. Form 1
* maintains CRR in cash and balances with banks; Form 9 maintains SLR in government securities.
*
* <h3>The lag basis, which is the part worth reading twice</h3>
*
* The base and the holdings are NOT read as at the same date. RBI reckons the requirement on the
* DTL of an earlier reporting Friday and compares it against what is actually held now - that lag
* is what gives a bank time to arrange its position. So:
*
* <pre>
* DTL as at the reporting Friday on or before (reporting date - 45 days)
* Required = DTL x rate in force
* Held as at the reporting date itself
* Surplus = Held - Required
* </pre>
*
* The 45 days come from the reporting requirement; snapping back to a Friday is what makes it a
* reporting basis rather than an arbitrary calendar day, since a Sunday has no close of business to
* reckon a balance at. Both dates are returned so the basis is auditable rather than implied - a
* reviewer can see the raw 45-day date and the Friday actually used.
*
* <h3>Reading balances</h3>
*
* Balances are read the way every other report in this codebase reads them (see
* BalanceSheetApiResource, ProfitAndLossApiResource): on entry_date, excluding reversed entries AND
* the counter entries that Fineract's own revertJournalEntry posts against them. Dropping only the
* original leg of that second kind would leave its counter entry standing alone and swing the
* balance by the full amount in the wrong direction.
*
* <h3>When something is not configured</h3>
*
* A role with no GL accounts pinned reports as unconfigured, and the return refuses to present a
* total that silently treats it as zero. A CRR return that reports zero holdings because nobody
* mapped the cash account is worse than one that says it cannot compute.
*/
@Path("/v1/rbireservereturn")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class RbiReserveReturnApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG";
/** Days the DTL base lags the reporting date, before snapping to a reporting Friday. */
private static final int DTL_LAG_DAYS = 45;
/**
* The share of the requirement that must be held on EVERY day of the fortnight, not merely on
* average across it. RBI sets this daily floor precisely so a bank cannot run the fortnight
* near-empty and make the average up on the closing day.
*/
private static final BigDecimal DAILY_MINIMUM_PERCENT = BigDecimal.valueOf(90);
private static final int ASSET = 1;
private static final int EXPENSE = 5;
/** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */
private static final int POSTABLE_ACCOUNT = 1;
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/** The two heads liabilities are reported under. */
private static final String DEMAND = "DEMAND";
private static final String TIME = "TIME";
/** Roles where the demand/time split applies. Elsewhere an unclassified row is not a gap. */
private static final java.util.Set<String> LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES");
/** How an administrator-defined head is consumed; see m_rbi_reserve_role.role_usage. */
private static final String USAGE_LIABILITY = "LIABILITY";
private static final String USAGE_ASSET_NET = "ASSET_NET";
private static final String USAGE_MAINTAINED = "MAINTAINED";
/**
* Counts toward SLR rather than CRR. The two are separate because the returns admit different
* assets: CRR is met in cash and balances with banks, SLR in cash, gold and unencumbered
* approved securities. A single "maintained" usage would let a head meant for one return
* quietly satisfy the other.
*/
private static final String USAGE_SLR_ASSET = "SLR_ASSET";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
/**
* Form 1 - CRR, filed twice a month.
*
* @param reportingDateParam
* defaults to the current business date.
*/
@GET
@Path("form1")
public String form1(@QueryParam("reportingDate") final String reportingDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam);
final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS);
// A reporting Friday that falls on a holiday has no close of business to strike a balance
// at, so the position is taken at the preceding working day instead.
final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate);
final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("formCode", "FORM_1");
response.put("formName", "Form 1 - Cash Reserve Ratio (CRR)");
response.put("frequency", "Fortnightly");
response.put("reportingDate", reportingDate.toString());
response.putAll(fortnightOf(reportingDate));
// The lag basis, spelled out so a reviewer can check it rather than trust it.
final boolean shifted = !dtlDate.equals(nominalDtlDate);
final Map<String, Object> basis = new LinkedHashMap<>();
basis.put("lagDays", DTL_LAG_DAYS);
basis.put("rawLagDate", rawLagDate.toString());
basis.put("reportingFriday", nominalDtlDate.toString());
basis.put("dtlAsOnDate", dtlDate.toString());
basis.put("holdingsAsOnDate", reportingDate.toString());
basis.put("shiftedForHoliday", shifted);
basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " ("
+ DTL_LAG_DAYS + " days before the reporting date)"
+ (shifted
? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the "
+ "preceding working day " + dtlDate
: "")
+ "; balances maintained are taken as at " + reportingDate + ".");
response.put("basis", basis);
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId);
final RoleFigure cash = figureFor("CASH_IN_HAND", reportingDate, officeId);
final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", reportingDate, officeId);
final Rate rate = rateInForce("CRR", reportingDate);
response.put("rateType", "CRR");
response.put("ratePercentage", rate == null ? null : rate.percentage);
response.put("rateIsSeedDefault", rate != null && rate.seedDefault);
// Inter-bank assets, read on the DTL date rather than the reporting date: they are netted
// off the liability base, so they belong to the same close of business the base is struck
// at. Read on the reporting date they would net a later position against an earlier base.
final RoleFigure interBankAtDtlDate = figureFor("BALANCE_WITH_OTHER_BANKS", dtlDate, officeId);
// Heads the bank added itself - a reserve, a tax payable, anything its chart carries that
// the four built-in roles do not name. Each declares how it is consumed, so they fold into
// the same three totals below without the arithmetic knowing what any of them is called.
final List<CustomHead> heads = customHeads("RESERVE", dtlDate, reportingDate, officeId);
final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY));
final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true));
final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false));
final BigDecimal netted = interBankAtDtlDate.amount.add(totalOf(heads, USAGE_ASSET_NET));
// NDTL = DTL - inter-bank assets. CRR and SLR are charged on the NET figure; charging the
// gross would overstate the requirement by the rate times these balances.
//
// Floored at zero. The subtraction can go negative where inter-bank assets exceed the
// liabilities, and a negative base yields a negative required amount - which would make any
// holding at all, including none, report as a surplus. There is no negative reserve
// requirement: below zero the requirement is nil.
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
final List<Map<String, Object>> lines = new ArrayList<>();
lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate));
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
lines.add(line(head.label, head.figure, dtlDate));
}
}
// The two heads the liability total is built from, each stated on the face of the return as
// RBI reports them rather than left implicit inside the total above.
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate));
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate));
lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate));
for (final CustomHead head : heads) {
if (USAGE_ASSET_NET.equals(head.usage)) {
lines.add(line("Less: " + head.label, head.figure, dtlDate));
}
}
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate));
lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate));
lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate));
for (final CustomHead head : heads) {
// A head belonging to the Memorandum or an Annexure prints under that section's own
// heading below, not here: listing it in both places would show one figure twice and
// invite a reader to add it in.
if (inNamedSection(head)) {
continue;
}
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure,
reportingDate));
}
}
response.put("lines", lines);
final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured);
response.put("computable", computable);
if (computable) {
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final BigDecimal held = cash.amount.add(banks.amount).add(totalOf(heads, USAGE_MAINTAINED));
response.put("dtlAmount", liabilities);
response.put("demandLiabilities", dtl.configured ? demand : null);
response.put("timeLiabilities", dtl.configured ? time : null);
response.put("interBankAssets", netted);
response.put("ndtlAmount", ndtl);
response.put("requiredAmount", required);
response.put("maintainedAmount", held);
response.put("surplusOrShortfall", held.subtract(required));
response.put("compliant", held.compareTo(required) >= 0);
// What the bank actually holds, as a percentage of the same base the requirement uses -
// directly comparable with the rate above.
response.put("maintainedPercentage", percentageOf(held, ndtl));
// Compliance over the fortnight, not on one day. RBI requires the full requirement on
// average across the fortnight AND a daily minimum on every single day of it, so a bank
// that held nothing for thirteen days and topped up on the last one fails even though
// its closing position looks ample.
// The maintenance table covers the whole calendar month, not the fortnight alone: a
// reviewer comparing two filings of the same month otherwise has to stitch two
// fortnights together by hand to see how the position moved across it.
final LocalDate fortnightStart = reportingDate.withDayOfMonth(1);
final LocalDate fortnightEnd = reportingDate.with(TemporalAdjusters.lastDayOfMonth());
final List<String> maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS"));
maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED));
// Only up to the reporting date: days after it have not happened, and averaging an
// unfinished fortnight over its whole length would understate what is held so far.
final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd;
final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles,
required);
final BigDecimal average = (BigDecimal) daily.get("average");
final BigDecimal lowest = (BigDecimal) daily.get("lowest");
final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2,
RoundingMode.HALF_UP);
daily.put("periodStart", fortnightStart.toString());
daily.put("periodEnd", fortnightEnd.toString());
daily.put("periodLabel", fortnightStart.getMonth().getDisplayName(java.time.format.TextStyle.FULL,
java.util.Locale.ENGLISH) + " " + fortnightStart.getYear());
daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
daily.put("requiredAverage", required);
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
daily.put("dailyMinimumAmount", dailyFloor);
daily.put("averageMet", average.compareTo(required) >= 0);
daily.put("dailyMinimumMet", lowest.compareTo(dailyFloor) >= 0);
daily.put("averageShortfall", average.subtract(required));
response.put("maintenance", daily);
}
// The Memorandum and the two Annexures Form A carries alongside the main statement. They are
// sections of the return rather than extra lines on it, so they are returned separately and
// print under their own headings rather than being mistaken for figures entering the CRR
// arithmetic - which none of them do.
response.put("memorandum", sectionOf(heads, "Memorandum - "));
response.put("annexureA", sectionOf(heads, "Annexure A - "));
response.put("annexureB", sectionOf(heads, "Annexure B - "));
response.put("unmetRequirements", unmet(rate, "CRR", dtl, cash, banks));
return gson.toJson(response);
}
/**
* The heads belonging to one titled section of the return, with the prefix stripped from each
* label so "Memorandum - Reserves" prints as "Reserves" under the Memorandum heading.
*
* Selected by label prefix rather than by a column, because a section is a presentational
* grouping: a head moves between sections by being renamed, and an administrator adding a head
* to the Memorandum does so by naming it that way rather than by learning a second concept.
*/
/** Prefixes that move a head out of the main statement and into its own titled section. */
private static final List<String> SECTION_PREFIXES = List.of("Memorandum - ", "Annexure A - ", "Annexure B - ");
private static boolean inNamedSection(final CustomHead head) {
if (head.label == null) {
return false;
}
return SECTION_PREFIXES.stream().anyMatch(head.label::startsWith);
}
private static List<Map<String, Object>> sectionOf(final List<CustomHead> heads, final String prefix) {
final List<Map<String, Object>> rows = new ArrayList<>();
for (final CustomHead head : heads) {
if (head.label == null || !head.label.startsWith(prefix)) {
continue;
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("label", head.label.substring(prefix.length()));
row.put("configKey", head.figure.configKey);
row.put("configured", head.figure.configured);
row.put("amount", head.figure.configured ? head.figure.amount : null);
row.put("accounts", head.figure.accounts);
rows.add(row);
}
return rows;
}
/**
* Form 9 - SLR, filed at the end of the month.
*
* Unlike Form 1, SLR is reported on the month-end position: base and holdings are both taken as
* at the month end, with no lag, so the return states the position actually held at close.
*/
@GET
@Path("form9")
public String form9(@QueryParam("reportingDate") final String reportingDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate requested = parseOrBusinessDate(reportingDateParam);
final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
final Map<String, Object> response = new LinkedHashMap<>();
response.put("formCode", "FORM_9");
response.put("formName", "Form 9 - Statutory Liquidity Ratio (SLR)");
response.put("frequency", "Monthly");
response.put("requestedDate", requested.toString());
response.put("reportingDate", monthEnd.toString());
response.put("monthStart", requested.withDayOfMonth(1).toString());
final Map<String, Object> basis = new LinkedHashMap<>();
basis.put("dtlAsOnDate", monthEnd.toString());
basis.put("holdingsAsOnDate", monthEnd.toString());
basis.put("note", "SLR is reported on the month-end position: both the DTL base and the "
+ "SLR-eligible investments are taken as at " + monthEnd + ".");
response.put("basis", basis);
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId);
final RoleFigure investments = figureFor("SLR_INVESTMENTS", monthEnd, officeId);
final RoleFigure cash = figureFor("CASH_IN_HAND", monthEnd, officeId);
final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", monthEnd, officeId);
final Rate rate = rateInForce("SLR", monthEnd);
response.put("rateType", "SLR");
response.put("ratePercentage", rate == null ? null : rate.percentage);
response.put("rateIsSeedDefault", rate != null && rate.seedDefault);
// The same administrator-defined heads Form 1 reads. Both dates are month-end here, so the
// base and the holdings are struck at one close of business.
final List<CustomHead> heads = customHeads("RESERVE", monthEnd, monthEnd, officeId);
final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY));
final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true));
final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false));
final BigDecimal netted = banks.amount.add(totalOf(heads, USAGE_ASSET_NET));
// SLR is charged on the same NET base as CRR. Both sides are month-end here, so the
// inter-bank figure is the one already read above rather than a second read at another date.
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
final List<Map<String, Object>> lines = new ArrayList<>();
lines.add(line("Demand and Time Liabilities (DTL)", dtl, monthEnd));
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
lines.add(line(head.label, head.figure, monthEnd));
}
}
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, monthEnd));
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, monthEnd));
lines.add(line("Less: balances with other banks — deducted from the DTL base", banks, monthEnd));
for (final CustomHead head : heads) {
if (USAGE_ASSET_NET.equals(head.usage)) {
lines.add(line("Less: " + head.label, head.figure, monthEnd));
}
}
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd));
// The heads that DO count toward SLR - gold, approved securities, eligible cash - each on
// its own line, so what makes up the maintained figure is visible rather than a single total
// a reader has to take on trust.
for (final CustomHead head : heads) {
if (USAGE_SLR_ASSET.equals(head.usage) && !inNamedSection(head)) {
lines.add(line(head.label, head.figure, monthEnd));
}
}
// Shown for completeness of the month-end liquid position; not added into SLR maintained.
lines.add(line("Cash in hand (memo)", cash, monthEnd));
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage) || USAGE_ASSET_NET.equals(head.usage)
|| USAGE_SLR_ASSET.equals(head.usage) || inNamedSection(head)) {
continue;
}
// Every remaining head is a memo on THIS return, including one marked MAINTAINED: that
// usage means CRR, so the label must not suggest the figure was added into SLR.
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
}
response.put("lines", lines);
// Form A's Memorandum and Annexures appear on Form 9 too - the same heads, under the same
// headings, so a reviewer reading either return finds them in the same place.
response.put("memorandum", sectionOf(heads, "Memorandum - "));
response.put("annexureA", sectionOf(heads, "Annexure A - "));
response.put("annexureB", sectionOf(heads, "Annexure B - "));
// Heads the bank declared as SLR assets - gold, approved securities, eligible cash. Summed
// here rather than on the built-in investments role alone, because SLR is maintained in
// more than government securities and a bank holding gold has no other place to state it.
final BigDecimal slrHeads = totalOf(heads, USAGE_SLR_ASSET);
final boolean anySlrAsset = investments.configured || hasUsage(heads, USAGE_SLR_ASSET);
final boolean computable = rate != null && dtl.configured && anySlrAsset;
response.put("computable", computable);
if (computable) {
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
// A head marked MAINTAINED is deliberately NOT added: that usage means CRR, which is met
// in cash and balances with banks. Letting it count here would allow a head configured
// for Form 1 to inflate the SLR position - exactly the error this return exists to
// detect. Only SLR_ASSET heads and the built-in investments role count.
final BigDecimal held = investments.amount.add(slrHeads);
response.put("dtlAmount", liabilities);
response.put("demandLiabilities", dtl.configured ? demand : null);
response.put("timeLiabilities", dtl.configured ? time : null);
response.put("interBankAssets", netted);
response.put("ndtlAmount", ndtl);
response.put("requiredAmount", required);
response.put("maintainedAmount", held);
response.put("surplusOrShortfall", held.subtract(required));
response.put("compliant", held.compareTo(required) >= 0);
response.put("maintainedPercentage", percentageOf(held, ndtl));
}
// The investments role is only reported as missing when nothing else stands in for it: a
// bank that maintains SLR in gold, or in securities mapped to its own heads, has configured
// the return even though that one built-in role is empty.
final List<String> slrUnmet = anySlrAsset ? unmet(rate, "SLR", dtl) : unmet(rate, "SLR", dtl, investments);
response.put("unmetRequirements", slrUnmet);
return gson.toJson(response);
}
// ─────────────────────────── computation ───────────────────────────
/** A role's balance, plus whether it was configured at all - zero and unset are different. */
private static final class RoleFigure {
final String configKey;
boolean configured;
BigDecimal amount = BigDecimal.ZERO;
/**
* The same total split by liability class, for the roles where RBI reports the two heads
* separately. Both stay zero on asset roles, where the distinction has no meaning.
*/
BigDecimal demandAmount = BigDecimal.ZERO;
BigDecimal timeAmount = BigDecimal.ZERO;
final List<Map<String, Object>> accounts = new ArrayList<>();
/** Accounts pinned to the role but left out of the total, with why. */
final List<String> excluded = new ArrayList<>();
/**
* Liability rows carrying no DEMAND/TIME class. They are counted in the role total, so the
* return still adds up, but they cannot be placed under either head - reported so the gap is
* visible rather than showing as a total its own two halves fall short of.
*/
final List<String> unclassified = new ArrayList<>();
RoleFigure(final String configKey) {
this.configKey = configKey;
}
}
/**
* Sums the balances of every GL account pinned to a role, as at a date.
*
* Each account's balance is taken on its own natural side, so a liability role such as DTL
* reports as a positive number rather than as a negative asset-side figure.
*
* A mapping row contributes its configured PORTION of the balance, not the whole of it. RBI
* treats part of a savings balance as a demand liability and the rest as time, so the same
* account is mapped twice under one role - once per class, each with its own weight - and the
* two slices are accumulated under their respective heads.
*/
private RoleFigure figureFor(final String configKey, final LocalDate asOnDate, final Long officeId) {
final RoleFigure figure = new RoleFigure(configKey);
// Every account pinned to the role is read back, usable or not. Excluding the unusable ones
// in SQL would shrink the maintained figure with nothing on the return to say an account had
// dropped out, so they are carried through here and reported by unmet() instead.
//
// Keyed on the mapping row rather than the account: one account can appear more than once
// under a role, and grouping by account would collapse a demand slice and a time slice into
// a single row whose weight is neither.
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT c.id AS mapping_id, c.weight_percentage, c.liability_class,
a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage
FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
WHERE c.config_key = ?
ORDER BY a.gl_code, c.liability_class
""", configKey);
if (accounts.isEmpty()) {
return figure;
}
figure.configured = true;
for (final Map<String, Object> account : accounts) {
final Long accountId = ((Number) account.get("id")).longValue();
final int classification = ((Number) account.get("classification_enum")).intValue();
final String glCode = (String) account.get("gl_code");
final String liabilityClass = (String) account.get("liability_class");
// NOT NULL in the schema, but read defensively: a row that somehow arrived without one
// must not multiply a balance by zero and silently drop it out of the return.
final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED
: (BigDecimal) account.get("weight_percentage");
// A pinned account can stop being usable after it was mapped: disabled, or turned into a
// header whose balance is the sum of its children and would double-count them. Either
// way its balance must not silently enter the total - the account is named on the return
// as excluded, so the figure can be read for what it is.
final boolean disabled = Boolean.TRUE.equals(account.get("disabled"));
final boolean header = account.get("account_usage") != null
&& ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT;
if (disabled || header) {
figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)"));
final Map<String, Object> skipped = new LinkedHashMap<>();
skipped.put("glAccountId", accountId);
skipped.put("glCode", glCode);
skipped.put("name", account.get("name"));
skipped.put("balance", null);
skipped.put("weightPercentage", weight);
skipped.put("liabilityClass", liabilityClass);
skipped.put("excluded", disabled ? "disabled" : "header");
figure.accounts.add(skipped);
continue;
}
final StringBuilder sql = new StringBuilder("""
SELECT 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 account_id = ? AND 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)
""");
final List<Object> args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(asOnDate)));
if (officeId != null) {
sql.append(" AND office_id = ?");
args.add(officeId);
}
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
final BigDecimal debit = (BigDecimal) row.get("total_debit");
final BigDecimal credit = (BigDecimal) row.get("total_credit");
final boolean debitNatured = classification == ASSET || classification == EXPENSE;
final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit);
// The role takes its configured portion of the balance. Rounded to paise at the slice,
// so the demand and time halves shown on the return are the very numbers that were
// added together - rounding only the total would leave the two heads failing to sum to
// it by a paisa, which on a filed return reads as an error.
final BigDecimal contribution = weight.compareTo(HUNDRED) == 0 ? balance
: balance.multiply(weight).divide(HUNDRED, 2, RoundingMode.HALF_UP);
figure.amount = figure.amount.add(contribution);
if (DEMAND.equals(liabilityClass)) {
figure.demandAmount = figure.demandAmount.add(contribution);
} else if (TIME.equals(liabilityClass)) {
figure.timeAmount = figure.timeAmount.add(contribution);
} else if (LIABILITY_ROLES.contains(configKey)) {
figure.unclassified.add(glCode + " — " + account.get("name"));
}
final Map<String, Object> detail = new LinkedHashMap<>();
detail.put("glAccountId", accountId);
detail.put("glCode", glCode);
detail.put("name", account.get("name"));
// Both the account's own balance and the slice taken from it. A reviewer checking a
// weighted line against the ledger needs the figure it was derived from, not only the
// result - otherwise a wrong weight and a wrong balance look identical on the return.
detail.put("accountBalance", balance);
detail.put("weightPercentage", weight);
detail.put("liabilityClass", liabilityClass);
detail.put("balance", contribution);
figure.accounts.add(detail);
}
return figure;
}
/** An administrator-defined head, with the figure it evaluated to. */
private static final class CustomHead {
final String label;
final String usage;
final RoleFigure figure;
CustomHead(final String label, final String usage, final RoleFigure figure) {
this.label = label;
this.usage = usage;
this.figure = figure;
}
}
/**
* The heads an administrator added to a return, each evaluated as at the date its treatment
* calls for.
*
* Liabilities are read on the base date and assets on the holdings date, for the same reason the
* built-in roles are: the base is struck at an earlier close of business than the position held
* against it, and reading both at one date would compare figures from different days.
*/
/**
* The keys of administrator-defined heads with one usage - so the daily average counts whatever
* the bank declared as counting toward the requirement, not only the two built-in roles.
*/
private List<String> customKeysWithUsage(final String group, final String usage) {
return jdbcTemplate.queryForList("""
SELECT config_key FROM m_rbi_reserve_role
WHERE role_group = ? AND role_usage = ?
ORDER BY position, id
""", String.class, group, usage);
}
private List<CustomHead> customHeads(final String group, final LocalDate baseDate, final LocalDate holdingsDate,
final Long officeId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT config_key, label, role_usage
FROM m_rbi_reserve_role
WHERE role_group = ?
ORDER BY position, label
""", group);
final List<CustomHead> heads = new ArrayList<>();
for (final Map<String, Object> row : rows) {
final String key = (String) row.get("config_key");
final String usage = (String) row.get("role_usage");
final LocalDate asOn = USAGE_LIABILITY.equals(usage) || USAGE_ASSET_NET.equals(usage) ? baseDate
: holdingsDate;
final RoleFigure figure = figureFor(key, asOn, officeId);
// A head with nothing mapped is left off the return entirely rather than printed as a
// nil line. It was added but never finished, and an empty row invites the reader to
// treat a gap in configuration as a balance of zero.
if (figure.configured) {
heads.add(new CustomHead((String) row.get("label"), usage, figure));
}
}
return heads;
}
/** The total a set of custom heads contributes under one treatment. */
/** Whether any head was declared with one usage, so "nothing mapped" can be told from "nil". */
private static boolean hasUsage(final List<CustomHead> heads, final String usage) {
return heads.stream().anyMatch(h -> usage.equals(h.usage) && h.figure.configured);
}
private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
BigDecimal total = BigDecimal.ZERO;
for (final CustomHead head : heads) {
if (head.usage.equals(usage)) {
total = total.add(head.figure.amount);
}
}
return total;
}
/** The demand or time slice those heads contribute, for the split shown on the face of the return. */
private static BigDecimal liabilitySliceOf(final List<CustomHead> heads, final boolean demand) {
BigDecimal total = BigDecimal.ZERO;
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
total = total.add(demand ? head.figure.demandAmount : head.figure.timeAmount);
}
}
return total;
}
/** A rate and whether it is still the placeholder the migration seeded. */
private static final class Rate {
final BigDecimal percentage;
final boolean seedDefault;
Rate(final BigDecimal percentage, final boolean seedDefault) {
this.percentage = percentage;
this.seedDefault = seedDefault;
}
}
/**
* The rate in force on a date - the most recent one effective on or before it. Null when the
* date precedes every rate on record, which stops the return computing rather than defaulting to
* a rate nobody set.
*/
private Rate rateInForce(final String rateType, final LocalDate onDate) {
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
SELECT percentage, is_seed_default FROM m_rbi_reserve_rate
WHERE rate_type = ? AND effective_from <= ?
ORDER BY effective_from DESC LIMIT 1
""", rateType, java.sql.Date.valueOf(onDate));
if (found.isEmpty()) {
return null;
}
final Map<String, Object> row = found.get(0);
return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default")));
}
/**
* The reporting Friday on or before a date. RBI reckons fortnightly balances at the close of
* business on alternate Fridays; a raw calendar offset can land on a Sunday, which has no close
* of business to read a balance at.
*/
private static LocalDate reportingFridayOnOrBefore(final LocalDate date) {
return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));
}
/**
* The date itself when the bank was open, otherwise the working day before it.
*
* A reporting Friday that is a declared holiday has no close of business, so there is no balance
* to strike: the statutory framework takes the preceding working day instead. Sundays are
* treated as non-working alongside declared holidays; Saturdays are not, because a reporting
* Friday never falls on one and a holiday shifted back from Friday legitimately lands on
* Thursday.
*
* Bounded at ten days. A chain of holidays longer than that is a data problem rather than a
* calendar, and walking back indefinitely would silently report a balance from weeks earlier.
*/
private LocalDate precedingWorkingDay(final LocalDate date) {
LocalDate candidate = date;
for (int i = 0; i < 10; i++) {
if (!isNonWorkingDay(candidate)) {
return candidate;
}
candidate = candidate.minusDays(1);
}
return date;
}
private boolean isNonWorkingDay(final LocalDate date) {
if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
return true;
}
// status_enum 300 is an activated holiday; a draft or deleted one does not close the bank.
final Integer holidays = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_holiday
WHERE status_enum = 300 AND ? BETWEEN from_date AND to_date
""", Integer.class, java.sql.Date.valueOf(date));
return holidays != null && holidays > 0;
}
/**
* The average of what was maintained across every day of the fortnight, which is the figure CRR
* compliance actually turns on.
*
* RBI does not read a single day's balance: a bank must keep at least a daily minimum and average
* the full requirement over the fortnight. A return that reported only the reporting date's
* position would call a bank compliant that held nothing for thirteen days and topped up on the
* last one.
*
* Every day is read, including holidays and Sundays: the balance stands over a closed day, and
* excluding those days would let a fortnight ending on a long weekend average away a shortfall.
*/
private Map<String, Object> dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId,
final List<String> maintainedRoles, final BigDecimal required) {
final List<Map<String, Object>> days = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
BigDecimal lowest = null;
LocalDate lowestOn = null;
for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) {
BigDecimal held = BigDecimal.ZERO;
for (final String role : maintainedRoles) {
held = held.add(figureFor(role, day, officeId).amount);
}
total = total.add(held);
if (lowest == null || held.compareTo(lowest) < 0) {
lowest = held;
lowestOn = day;
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("date", day.toString());
// Each day carries the requirement it was measured against and the gap, so a reader can
// see WHY a day passed or failed without recomputing the percentage themselves.
row.put("required", required);
row.put("amount", held);
row.put("surplus", held.subtract(required));
days.add(row);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("days", days);
out.put("dayCount", days.size());
out.put("average", days.isEmpty() ? BigDecimal.ZERO
: total.divide(BigDecimal.valueOf(days.size()), 2, RoundingMode.HALF_UP));
out.put("lowest", lowest == null ? BigDecimal.ZERO : lowest);
out.put("lowestOn", lowestOn == null ? null : lowestOn.toString());
return out;
}
/** Which half of the month a reporting date falls in - Form 1 is filed once per fortnight. */
private static Map<String, Object> fortnightOf(final LocalDate date) {
final boolean first = date.getDayOfMonth() <= 15;
final LocalDate start = first ? date.withDayOfMonth(1) : date.withDayOfMonth(16);
final LocalDate end = first ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth());
final Map<String, Object> out = new LinkedHashMap<>();
out.put("fortnight", first ? 1 : 2);
out.put("fortnightLabel", (first ? "First" : "Second") + " fortnight");
out.put("fortnightStart", start.toString());
out.put("fortnightEnd", end.toString());
return out;
}
/**
* A figure as a percentage of a base, or null where that percentage would not mean anything.
*
* Null on a base of zero OR LESS, not only on zero: the base is now NDTL, which is a subtraction
* and can come out negative where inter-bank assets exceed the liabilities - an unusual position,
* but one a small bank can genuinely hold. Dividing by it yields a negative "maintained %" that
* reads as a shortfall on a return where holdings are ample, which is worse than showing nothing.
*/
private static BigDecimal percentageOf(final BigDecimal amount, final BigDecimal base) {
if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
return amount.multiply(HUNDRED).divide(base, 4, RoundingMode.HALF_UP);
}
/**
* A derived row - a split of a role's total, or a figure computed from several roles. It has no
* accounts of its own because it is not read from the ledger: its parts are already listed under
* the lines it was derived from, and repeating them here would show the same balance twice.
*/
private static Map<String, Object> subLine(final String label, final BigDecimal amount, final boolean configured,
final LocalDate asOnDate) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("configKey", null);
out.put("configured", configured);
out.put("asOnDate", asOnDate.toString());
out.put("amount", configured ? amount : null);
out.put("derived", true);
out.put("accounts", List.of());
return out;
}
private static Map<String, Object> line(final String label, final RoleFigure figure, final LocalDate asOnDate) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("configKey", figure.configKey);
out.put("configured", figure.configured);
out.put("asOnDate", asOnDate.toString());
// Null rather than zero when unconfigured: the screen must be able to tell "nothing mapped"
// from "mapped and genuinely nil".
out.put("amount", figure.configured ? figure.amount : null);
out.put("accounts", figure.accounts);
return out;
}
/**
* Human-readable reasons the return could not be computed, for the screen to show verbatim.
*
* A rate still carrying the seeded placeholder is listed here too. It does not stop the return
* computing - the figures are arithmetically right for that rate - but the rate itself is one
* nobody at the bank confirmed, so the return says so rather than presenting a required amount
* derived from a placeholder as though it were settled.
*/
private static List<String> unmet(final Rate rate, final String rateType, final RoleFigure... figures) {
final List<String> reasons = new ArrayList<>();
if (rate == null) {
reasons.add("No " + rateType + " rate is on record effective on or before the reporting date. "
+ "Set one under Organization > RBI Reserve Configuration.");
} else if (rate.seedDefault) {
reasons.add("The " + rateType + " rate of " + rate.percentage
+ "% is the placeholder shipped with this installation, not a rate this bank has confirmed. "
+ "Confirm the rate in force under Organization > RBI Reserve Configuration before filing.");
}
for (final RoleFigure figure : figures) {
if (!figure.configured) {
reasons.add("No GL accounts are mapped to " + figure.configKey
+ ". Map them under Organization > RBI Reserve Configuration.");
} else if (!figure.unclassified.isEmpty()) {
// Counted in the base but placed under neither head, so the demand and time lines do
// not sum to the total printed above them. On a filed return that reads as an
// arithmetic error, so the accounts responsible are named.
reasons.add(String.join(", ", figure.unclassified)
+ " — mapped to the liability base but not marked Demand or Time, so the figure is counted in "
+ "NDTL while appearing under neither head. Classify them under Organization > "
+ "RBI Reserve Configuration.");
}
if (!figure.excluded.isEmpty()) {
// The figure is short by these accounts' balances. Saying so is the difference
// between a reviewable return and one that under-reports without a trace.
reasons.add(figure.configKey + " leaves out " + String.join(", ", figure.excluded)
+ " — mapped but no longer postable, so the balance shown excludes them. "
+ "Review the mapping under Organization > RBI Reserve Configuration.");
}
}
return reasons;
}
private static LocalDate parseOrBusinessDate(final String param) {
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService;
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 SaveRbiFormAReturnJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RbiFormAAutoSaveService rbiFormAAutoSaveService;
@Bean
public SaveRbiFormAReturnTasklet saveRbiFormAReturnTasklet() {
return new SaveRbiFormAReturnTasklet(rbiFormAAutoSaveService);
}
@Bean
protected Step saveRbiFormAReturnStep() {
return new StepBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository)
.tasklet(saveRbiFormAReturnTasklet(), transactionManager).build();
}
@Bean
public Job saveRbiFormAReturnJob() {
return new JobBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository).start(saveRbiFormAReturnStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService;
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;
/**
* Runs the Form A auto-save. The service decides whether today actually closes a fortnight, so the
* tasklet stays a thin trigger and the rule lives in one place.
*/
@RequiredArgsConstructor
public class SaveRbiFormAReturnTasklet implements Tasklet {
private final RbiFormAAutoSaveService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
service.saveCurrentFortnight();
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,153 @@
/**
* 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.rbireserve.service;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.rbireserve.api.RbiReserveReturnApiResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Keeps a copy of each fortnight's Form A without anyone having to remember to.
*
* Form 1 is filed twice a month, and what it reports is a position that cannot be recovered later: a
* backdated journal entry, a re-mapped GL account or a corrected rate all move figures that were
* already reported, so a fortnight nobody saved is a fortnight whose filed statement no longer
* exists anywhere. The job runs on the days the fortnight closes and stores the return as it stands
* at that close.
*
* It saves as DRAFT and nothing more. Checking, approving and submitting stay human acts - the job
* exists so the figures survive, not so a return files itself.
*
* It never overwrites: a fortnight already saved, by hand or by an earlier run, is left exactly as
* it is. Re-running the job must not replace a statement somebody has already reviewed.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RbiFormAAutoSaveService {
private static final String FORM_CODE = "FORM_1";
private final JdbcTemplate jdbcTemplate;
private final RbiReserveReturnApiResource returnApiResource;
private final Gson gson = new Gson();
/**
* Saves the fortnight that closes on the business date, if it has not been saved already.
*
* Scheduled for the 15th and the last day of each month, but it does not trust the schedule: it
* checks the date itself, so a mis-set cron or a manual run on the wrong day cannot file a
* fortnight that has not closed.
*/
@Transactional
public void saveCurrentFortnight() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth());
final boolean fortnightCloses = today.getDayOfMonth() == 15 || today.equals(monthEnd);
if (!fortnightCloses) {
log.debug("RBI Form A auto-save: {} is not a fortnight end, nothing to save.", today);
return;
}
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class,
FORM_CODE, java.sql.Date.valueOf(today));
if (existing != null && existing > 0) {
log.info("RBI Form A auto-save: {} is already saved, leaving it untouched.", today);
return;
}
final JsonObject report;
try {
// Computed through the same endpoint the screen uses, so an auto-saved return and one
// saved by hand cannot come out differently.
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
} catch (final RuntimeException e) {
// A return that cannot be computed is not an error worth failing the batch over - an
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
// once it is filled. Logged rather than thrown so one tenant's gap does not stop others.
log.warn("RBI Form A auto-save: could not compute the return for {}: {}", today, e.getMessage());
return;
}
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
today);
return;
}
final LocalDate periodStart = date(report, "fortnightStart");
final LocalDate periodEnd = date(report, "fortnightEnd");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
}
jdbcTemplate.update("""
INSERT INTO m_rbi_form_a_filing
(form_code, reporting_date, fortnight_end, period_start, period_end, status, report_json,
ndtl_amount, required_amount, maintained_amount, average_maintained, rate_percentage,
remarks, created_on)
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
""", FORM_CODE, java.sql.Date.valueOf(today),
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
"Saved automatically at the close of the fortnight.");
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
}
/** Offices the return could be scoped to. Unused today - the job saves the consolidated return. */
@SuppressWarnings("unused")
private List<Map<String, Object>> offices() {
return jdbcTemplate.queryForList("SELECT id, name FROM m_office ORDER BY id");
}
private static java.sql.Date sqlDate(final LocalDate date) {
return date == null ? null : java.sql.Date.valueOf(date);
}
private static BigDecimal decimal(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
return object.get(field).getAsBigDecimal();
}
private static LocalDate date(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
final String value = object.get(field).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
}
@@ -227,14 +227,14 @@ public class CurrentProductsApiResource {
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
if (savingProductData.hasAccountingEnabled()) {
// Read under the product's own CURRENT type. Read as a savings product it returned the GLs of whichever
// savings product shares this id. Payment-channel and per-charge mappings are savings features a current
// product does not write, so none are returned.
final Map<String, Object> accountingMappings = accountMappingReadPlatformService
.fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId());
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = accountMappingReadPlatformService
.fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId);
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = accountMappingReadPlatformService
.fetchFeeToIncomeAccountMappingsForSavingsProduct(productId);
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = accountMappingReadPlatformService
.fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId);
.fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId());
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = null;
final Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = null;
final Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = null;
savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings,
paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings);
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic
* `payment` command (the same one the account's own Account Actions menu already uses) -- so the
* Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options,
* Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the
* gap-period interest credited first when the product's renewal grace window has passed. A no-op
* (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all --
* see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.")
@RequiredArgsConstructor
public class FixedDepositGapInterestApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String creditGapInterest(@PathParam("accountId") final Long accountId) {
// Posts a real interest transaction when past grace, so it needs write/update, not just read.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final Map<String, Object> result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId);
return this.gson.toJson(result);
}
}
@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
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.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the
* real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no
* closure instruction to auto-process -- with just the fields the maturity ladder needs
* (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays
* here until it's actually renewed or closed, since that's exactly the "needs manual attention"
* state the ladder exists to surface.
*
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
* m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/
* closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it
* needed for the single-account and full-list screens it serves, none of it needed here. Neither
* m_savings_account.deposit_type_enum/status_enum nor
* m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed
* directly against pg_indexes), so that query was a full table scan on top of being wide. This
* endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause
* this feature added (see migration 0571).
*/
@Path("/v1/fixeddepositaccounts/maturity-dashboard")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.")
@RequiredArgsConstructor
public class FixedDepositMaturityDashboardApiResource {
/** Fineract's DepositAccountType.FIXED_DEPOSIT. */
private static final int FIXED_DEPOSIT = 200;
/** SavingsAccountStatusType.ACTIVE. */
private static final int ACTIVE = 300;
/** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */
private static final int MATURED = 800;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve() {
// Same permission as the single-account/full-list FD endpoints -- this is a narrower read of
// the same resource, not a new one, so no new permission is introduced for it.
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.display_name AS "clientName",
sp.name AS "productName",
sp.fd_type AS "fdType",
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate",
datp.deposit_amount AS "depositAmount",
datp.maturity_amount AS "maturityAmount",
datp.on_account_closure_enum AS "onClosureType",
sa.linked_account_id AS "linkedAccountId",
dptp.renewal_grace_period_days AS "renewalGracePeriodDays"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?)
ORDER BY datp.maturity_date ASC
""", FIXED_DEPOSIT, ACTIVE, MATURED);
return this.gson.toJson(rows);
}
}
@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the
* original maturity date within the product's renewal grace window (0 days if none is configured), or
* from today with a gap-period interest credit (at the linked Savings account's rate) once the grace
* window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is
* made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored
* maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not
* consulted. All the actual renewal and gap-interest logic lives in
* DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same
* collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/renew")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.")
@RequiredArgsConstructor
public class FixedDepositRenewalApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String renew(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
// A renewal moves money (or credits gap-period interest), so it needs the write/update
// permission on this resource, not just read -- unlike the dashboard's own read-only endpoint.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject()
: JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull())
? body.get("reinvestType").getAsInt()
: null;
final Map<String, Object> result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType);
return this.gson.toJson(result);
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
/**
* Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms
* anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current
* balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the
* balance the new term will actually start from -- exactly the numbers
* {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the
* account, so this only needs read permission, unlike its posting sibling.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.")
@RequiredArgsConstructor
public class FixedDepositRenewalPreviewApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("accountId") final Long accountId) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final Map<String, Object> result = this.depositAccountWritePlatformService.previewFdRenewal(accountId);
return this.gson.toJson(result);
}
}
@@ -36,6 +36,8 @@ import lombok.Getter;
@Builder
public class DormantAccountData {
/** SAVINGS or CURRENT: which kind of account accountId refers to. */
private final String accountType;
private final Long accountId;
private final String accountNo;
private final Long clientId;
@@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest()) {
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) {
// Renews on the SAME account, so the customer keeps one account number however many times
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
// rollover itself -- which also removes the transfer-suspense round trip the old
// close-and-reopen flow needed.
// the deposit rolls over -- Reinvest Principal Only renews just the closing principal here
// (below), with only the leftover interest actually leaving, rather than the legacy
// close-and-reopen this branch used to use, which left the customer with a second account
// number for something that renews here just as cleanly as Reinvest Principal + Interest
// does. Full reinvest itself still moves nothing: the whole matured balance simply becomes
// the new term's principal, so there is no withdrawal, no deposit and no journal entry for
// the rollover itself.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance;
// Whatever doesn't renew is interest the customer earned and has to actually be paid out
// somewhere -- validated before anything below runs, so a missing target fails cleanly with
// nothing done rather than leaving a half-completed reinvestment.
final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount);
if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
throw new GeneralPlatformDomainRuleException(
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
}
// The active CBS base interest-rate version for this FD type: the same rate card the Base
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
@@ -337,52 +350,32 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) {
// "Re-invest principal only" still closes and reopens, because only the principal carries
// forward and the interest has to leave the deposit -- which needs a payout destination that
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
// postJournalEntries() call at the end of this method posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
// Reached from the maturity job processing the account's standing maturity instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
// Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is
// zero (renewedAmount already equals maturedBalance), so this stays a no-op for it.
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null,
"Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
@@ -723,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
// Only on this first-ever close of this term -- otherwise a term that renews more than once
// (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the
// same chart against the same closingRow every time, piling up duplicate slab rows for a
// chart that only ever needs archiving once.
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
}
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow);
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
@@ -856,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
* the existing manual Close command still uses today.
* transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and
* Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just
* Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with
* only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}
* this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's
* own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an
* already-stored instruction).
* <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
@@ -925,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and
// would otherwise accept any date at all -- including one behind already-posted interest, which
// the renewal would then turn into a pivot that reverses and re-posts it.
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// These three branches keep the account open, so they never reach close()/prematureClosure()
// and would otherwise accept any date at all -- including one behind already-posted interest,
// which the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
@@ -944,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Exactly the closing term's own principal -- the fixed point on the same split Partial
// Reinvest lets staff pick freely. Renews in place same as the other two, on just this
// account, with only the interest actually leaving (below) -- no second account, unlike the
// legacy close-and-reopen this branch used to do.
renewedAmount = closingPrincipal;
} else {
renewedAmount = maturedBalance;
}
@@ -976,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// The kept portion never left the account -- renewInPlace() just relabels it as the new
// term's principal -- so only the remainder is an actual money movement, posted the same
// way the Transfer to Savings branch below posts its full-balance transfer.
// term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is
// an actual money movement, posted the same way the Transfer to Savings branch below posts
// its full-balance transfer.
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
}
} else if (onClosureType.isTransferToSavings()) {
@@ -23,6 +23,7 @@ import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.service.CurrentAccountDormancyJobService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.batch.core.StepContribution;
@@ -38,6 +39,7 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet {
private final SavingsAccountReadPlatformService savingAccountReadPlatformService;
private final SavingsAccountWritePlatformService savingsAccountWritePlatformService;
private final CurrentAccountDormancyJobService currentAccountDormancyJobService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
@@ -63,6 +65,9 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet {
savingsAccountWritePlatformService.escheat(savingsId);
}
}
// Current accounts run the same ladder on their own current product's settings.
currentAccountDormancyJobService.run(tenantLocalDate);
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount;
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.springframework.stereotype.Service;
/**
* The cash GL a current account's deposit or withdrawal posts against.
*
* <p>
* A current product maps only the customer's liability (Current Control); the cash side is the branch's cash, which
* is the same GL the cashier's Transaction Entry screen uses: the Financial Activity mapping, Cash at Main Vault first
* and Cash at Teller after it -- the order that screen picks in, so the cashier and the ledger always name the same GL.
*/
@Service
@RequiredArgsConstructor
public class CurrentAccountCashGlResolver {
private final FinancialActivityAccountRepository financialActivityAccountRepository;
/** The branch cash GL, or null when neither cash activity is mapped. */
public GLAccount resolve() {
for (final FinancialActivity activity : new FinancialActivity[] { FinancialActivity.CASH_AT_MAINVAULT,
FinancialActivity.CASH_AT_TELLER }) {
final FinancialActivityAccount mapping = financialActivityAccountRepository.findByFinancialActivityType(activity.getValue());
if (mapping != null && mapping.getGlAccount() != null) {
return mapping.getGlAccount();
}
}
return null;
}
}
@@ -0,0 +1,95 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* The dormancy ladder for current accounts, run by the same nightly job as savings.
*
* <p>
* Current accounts live in m_current_account, which the savings job never reads, so until now a current account was
* never marked inactive or dormant and never escheated. This walks the same three rungs -- Inactive, Dormant, Escheat
* -- against each account's own current product (m_current_product: is_dormancy_tracking_active and its three day
* counts), measuring idle days with DormancyClock exactly as savings does, so the two kinds of account can never
* disagree about the same customer.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CurrentAccountDormancyJobService {
private static final int SUB_NONE = 0;
private static final int SUB_INACTIVE = 100;
private static final int SUB_DORMANT = 200;
private final JdbcTemplate jdbcTemplate;
private final DatabaseSpecificSQLGenerator sqlGenerator;
private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService;
public void run(final LocalDate businessDate) {
// A re-KYC restarts the clock (DormancyClock), so an account stamped Inactive/Dormant whose idle days are now
// back under the inactive threshold has been reactivated: clear the stamp before climbing the ladder again.
final int reactivated = jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = 0 WHERE id IN ("
+ "SELECT ca.id FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id"
+ " WHERE ca.status_enum = 300 AND ca.sub_status_enum IN (100, 200) AND cp.days_to_inactive > 0"
+ " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " < cp.days_to_inactive)", businessDate);
if (reactivated > 0) {
log.info("[Dormancy] {} current accounts reactivated after re-KYC", reactivated);
}
final int inactive = stamp(businessDate, SUB_NONE, "days_to_inactive", SUB_INACTIVE);
final int dormant = stamp(businessDate, SUB_INACTIVE, "days_to_dormancy", SUB_DORMANT);
int escheated = 0;
for (final Long accountId : pending(businessDate, SUB_DORMANT, "days_to_escheat")) {
try {
if (currentAccountTransactionWritePlatformService.escheat(accountId)) {
escheated++;
}
} catch (RuntimeException e) {
log.error("[Dormancy] Escheat failed for current account {}: {}", accountId, e.getMessage(), e);
}
}
log.info("[Dormancy] Current accounts: {} marked inactive, {} marked dormant, {} escheated", inactive, dormant, escheated);
}
/** Moves every account on rung {@code from} whose idle days reached {@code daysColumn} up to {@code to}. */
private int stamp(final LocalDate businessDate, final int from, final String daysColumn, final int to) {
final List<Long> ids = pending(businessDate, from, daysColumn);
for (final Long accountId : ids) {
jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = ? WHERE id = ? AND status_enum = 300 AND sub_status_enum = ?",
to, accountId, from);
}
return ids.size();
}
private List<Long> pending(final LocalDate businessDate, final int subStatus, final String daysColumn) {
final String sql = "SELECT ca.id FROM m_current_account ca"
+ " JOIN m_current_product cp ON cp.id = ca.product_id AND cp.is_dormancy_tracking_active = true"
+ " WHERE ca.status_enum = 300 AND ca.sub_status_enum = ? AND cp." + daysColumn + " > 0"
+ " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " >= cp." + daysColumn;
return jdbcTemplate.queryForList(sql, Long.class, subStatus, businessDate);
}
}
@@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -23,12 +23,14 @@ import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
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.service.DateUtils;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -71,9 +73,61 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
response.put("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId));
response.put("accountFacilityConfig", retrieveFacilityConfig(accountId));
addDormancy(accountId, response);
return response;
}
/**
* The dormancy countdown the account screen's Dormancy Risk Monitor reads, measured exactly as a savings account's
* is: from DormancyClock's last-live date (activation or the latest re-KYC), against the product's thresholds and
* the business date.
*
* The product's dormancyAlert decides whether the screen shows the monitor at all. subStatus is replaced by the
* stage the elapsed days have earned, as the savings read does, because no job stamps current accounts yet; a
* Block stamp (300 and above) is not a dormancy stage and is left alone.
*/
private void addDormancy(final Long accountId, final Map<String, Object> response) {
final String sql = "SELECT cp.dormancy_alert, cp.is_dormancy_tracking_active, cp.days_to_inactive, cp.days_to_dormancy,"
+ " cp.days_to_escheat, ca.status_enum, ca.sub_status_enum, " + DormancyClock.lastLiveOn("ca") + " AS last_live_on"
+ " FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id WHERE ca.id = ?";
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, accountId);
if (rows.isEmpty()) {
return;
}
final Map<String, Object> row = rows.get(0);
final boolean tracking = toBoolean(row.get("is_dormancy_tracking_active"));
response.put("dormancyAlert", toBoolean(row.get("dormancy_alert")));
response.put("isDormancyTrackingActive", tracking);
final LocalDate lastLive = row.get("last_live_on") instanceof java.sql.Date d ? d.toLocalDate() : null;
response.put("lastActiveTransactionDate", lastLive == null ? null : lastLive.toString());
if (!tracking || lastLive == null || toInt(row.get("status_enum"), 100) != 300) {
return;
}
final Integer toInactive = row.get("days_to_inactive") == null ? null : toInt(row.get("days_to_inactive"), 0);
final Integer toDormant = row.get("days_to_dormancy") == null ? null : toInt(row.get("days_to_dormancy"), 0);
final Integer toEscheat = row.get("days_to_escheat") == null ? null : toInt(row.get("days_to_escheat"), 0);
final LocalDate today = DateUtils.getBusinessLocalDate();
final long idle = ChronoUnit.DAYS.between(lastLive, today);
int subStatus = toInt(row.get("sub_status_enum"), 0);
if (subStatus < 300) {
subStatus = toDormant != null && toDormant > 0 && idle >= toDormant ? 200
: toInactive != null && toInactive > 0 && idle >= toInactive ? 100 : 0;
response.put("subStatus", buildSubStatus(subStatus));
}
if (subStatus < 100 && toInactive != null) {
response.put("daysToInactive", ChronoUnit.DAYS.between(today, lastLive.plusDays(toInactive)));
}
if (subStatus < 200 && toDormant != null) {
response.put("daysToDormancy", ChronoUnit.DAYS.between(today, lastLive.plusDays(toDormant)));
}
if (toEscheat != null) {
response.put("daysToEscheat", ChronoUnit.DAYS.between(today, lastLive.plusDays(toEscheat)));
}
}
private Map<String, Object> retrieveFacilityConfig(Long accountId) {
final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?";
List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
@@ -207,7 +261,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
tt.put("rejectTransfer", false);
tt.put("writtenoff", false);
tt.put("overdraftFee", false);
tt.put("escheat", false);
tt.put("escheat", typeEnum == 19);
tt.put("amountHold", typeEnum == 8);
tt.put("amountRelease", typeEnum == 9);
tt.put("accrual", false);
@@ -219,6 +273,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
case 7 -> { tt.put("code", "savingsTransactionType.payCharge"); tt.put("value", "Charge Deduction"); }
case 8 -> { tt.put("code", "savingsTransactionType.onHold"); tt.put("value", "Amount on hold"); }
case 9 -> { tt.put("code", "savingsTransactionType.release"); tt.put("value", "Release Amount"); }
case 19 -> { tt.put("code", "savingsTransactionType.escheat"); tt.put("value", "Escheat"); }
default -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); }
}
return tt;
@@ -477,6 +532,8 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
out.put("allowOverdraft", row.get("allow_overdraft"));
out.put("overdraftLimit", row.get("overdraft_limit"));
out.put("minOverdraftForInterestCalculation", row.get("min_overdraft_for_interest_calculation"));
out.put("nominalAnnualInterestRateOverdraft", row.get("nominal_annual_interest_rate_overdraft"));
out.put("validForPeriod", row.get("valid_for_period"));
out.put("minRequiredBalance", row.get("min_required_balance"));
out.put("enforceMinRequiredBalance", row.get("enforce_min_required_balance"));
out.put("onHoldFunds", row.get("on_hold_funds_derived"));
@@ -576,12 +633,14 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
Map<String, Object> s = new LinkedHashMap<>();
s.put("id", subStatusEnum);
String value = switch (subStatusEnum) {
// Same codes as savings (SavingsAccountSubStatusEnum), so both kinds of account read alike.
case 0 -> "None";
case 100 -> "Inactive";
case 200 -> "Dormant";
case 300 -> "Block";
case 400 -> "Block Debit";
case 300 -> "Escheat";
case 400 -> "Block";
case 500 -> "Block Credit";
case 600 -> "Block Debit";
default -> "None";
};
s.put("code", "SavingsAccountSubStatusEnum." + value.replace(" ", ""));
@@ -589,9 +648,10 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
s.put("none", subStatusEnum == 0);
s.put("inactive", subStatusEnum == 100);
s.put("dormant", subStatusEnum == 200);
s.put("block", subStatusEnum == 300);
s.put("blockDebit", subStatusEnum == 400);
s.put("escheat", subStatusEnum == 300);
s.put("block", subStatusEnum == 400);
s.put("blockCredit", subStatusEnum == 500);
s.put("blockDebit", subStatusEnum == 600);
return s;
}
@@ -689,13 +749,15 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
};
}
// SavingsPostingInterestPeriodType codes -- the ones the product form saves and the account copies.
private String interestPostingPeriodValue(int id) {
return switch (id) {
case 1 -> "Monthly";
case 2 -> "Quarterly";
case 3 -> "Biannual";
case 4 -> "Annually";
default -> "Annually";
case 1 -> "Daily";
case 4 -> "Monthly";
case 5 -> "Quarterly";
case 6 -> "Biannual";
case 7 -> "Annually";
default -> "Monthly";
};
}
@@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
@@ -45,7 +46,9 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Slf4j
@@ -63,6 +66,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
private final JournalEntryRepository journalEntryRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final KycRestrictionGuard kycRestrictionGuard;
private final AppUserRepositoryWrapper appUserRepositoryWrapper;
private final CurrentAccountCashGlResolver cashGlResolver;
@Transactional
@Override
@@ -77,6 +83,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount");
validatePositiveAmount(amount, accountId, "deposit");
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.CREDIT);
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT");
@@ -123,6 +130,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount");
validatePositiveAmount(amount, accountId, "withdrawal");
// A dormant account (its current product's dormancy days passed with no re-KYC) takes no debit
// until the customer is re-verified; the same rule the savings counter applies.
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
final BigDecimal currentBalance = safeBalance(account.getAccountBalanceDerived());
final BigDecimal onHold = safeBalance(account.getOnHoldFundsDerived());
@@ -159,7 +169,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate,
"WITHDRAWAL");
// Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out)
// Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out)
postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal");
return new CommandProcessingResultBuilder()
@@ -391,6 +401,59 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
.build();
}
/**
* Escheat, as the savings dormancy ladder does it: the balance goes to the product's escheat liability (Dr Current
* Control, Cr Escheat Liability) and the account is closed with sub-status Escheat (300) by the system user.
*
* Its own transaction, so one account that cannot be escheated never rolls back the rest of the night's batch.
* Refused -- logged, account untouched -- where the move would be wrong or unrecorded: a debit balance is money the
* customer owes, not unclaimed money; a hold is a lien someone else has on the funds; and with cash accounting a
* balance must not leave the account unless the escheat GL exists to receive it.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public boolean escheat(final Long accountId) {
final CurrentAccount account = currentAccountRepository.findById(accountId).orElse(null);
if (account == null || !account.isActive()) {
return false;
}
final BigDecimal balance = safeBalance(account.getAccountBalanceDerived());
if (balance.signum() < 0 || safeBalance(account.getOnHoldFundsDerived()).signum() > 0) {
log.warn("[Escheat] Current account {} skipped: debit balance {} or funds on hold {}", accountId, balance,
account.getOnHoldFundsDerived());
return false;
}
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
final boolean accounted = product != null && product.getAccountingType() != null && product.getAccountingType() != 1;
final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue());
final GLAccount escheatAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
if (accounted && balance.signum() > 0 && (controlAccount == null || escheatAccount == null)) {
log.warn("[Escheat] Current account {} skipped: product {} has no Current Control or Escheat Liability GL mapped",
accountId, account.getProductId());
return false;
}
final LocalDate today = DateUtils.getBusinessLocalDate();
if (balance.signum() > 0) {
final CurrentAccountTransaction txn = CurrentAccountTransaction.escheat(account, account.getOffice(), balance, today);
txn.setRunningBalanceDerived(BigDecimal.ZERO);
currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(BigDecimal.ZERO);
account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(balance));
if (accounted) {
final String ref = "CAT" + txn.getId();
final String description = "Current Account Escheat";
postGlEntry(account, controlAccount, JournalEntryType.DEBIT, balance, today, ref, description);
postGlEntry(account, escheatAccount, JournalEntryType.CREDIT, balance, today, ref, description);
}
}
account.setSubStatusEnum(300);
account.close(today, appUserRepositoryWrapper.fetchSystemUser());
currentAccountRepository.saveAndFlush(account);
log.info("[Escheat] Current account {} escheated, balance {} moved to escheat liability", accountId, balance);
return true;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
private CurrentAccount findActiveAccount(final Long accountId) {
@@ -443,37 +506,51 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
}
}
// GL: cashEntryType=DEBIT/CREDIT for SAVINGS_REFERENCE; controlEntryType for SAVINGS_CONTROL
/**
* GL: cashEntryType for the branch cash GL, controlEntryType for the account's own product's Current Control.
*
* <p>
* Mandatory when the product keeps accounts: a GL that cannot be resolved or posted throws, rolling the whole
* transaction back, so a balance can never move without its journal entry. It used to log and return, and a
* deposit whose Current Control lookup failed moved the balance with no entry at all.
*/
private void postTransactionGl(final CurrentAccount account, final CurrentAccountTransaction txn,
final JournalEntryType cashEntryType, final JournalEntryType controlEntryType, final String description) {
try {
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
if (product == null) return;
final Integer accountingType = product.getAccountingType();
if (accountingType == null || accountingType == 1) return; // NONE = 1
final GLAccount cashAccount = findGlAccount(account.getProductId(),
CashAccountsForSavings.SAVINGS_REFERENCE.getValue());
final GLAccount controlAccount = findGlAccount(account.getProductId(),
CashAccountsForSavings.SAVINGS_CONTROL.getValue());
if (cashAccount == null || controlAccount == null) {
log.warn("[GL] Missing GL account mapping for current account product id={}", account.getProductId());
return;
}
final String ref = "CAT" + txn.getId();
postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
} catch (Exception e) {
log.error("[GL] Failed GL posting for CA transaction id={}: {}", txn.getId(), e.getMessage(), e);
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
if (product == null) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.product.not.found",
"Current product " + account.getProductId() + " of account " + account.getAccountNo() + " not found.",
account.getId());
}
final Integer accountingType = product.getAccountingType();
if (accountingType == null || accountingType == 1) {
return; // NONE = 1: the product keeps no accounts
}
// Cash side: the branch cash GL from the Financial Activity mapping -- the same one the cashier screen uses.
// Liability side: the account's own current product's Current Control, read under its CURRENT product type.
final GLAccount cashAccount = cashGlResolver.resolve();
if (cashAccount == null) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped",
"No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.",
account.getId());
}
final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue());
if (controlAccount == null) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped",
"Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.",
account.getId());
}
final String ref = "CAT" + txn.getId();
postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
}
private GLAccount findGlAccount(final Long productId, final int financialAccountType) {
final var mapping = productToGLAccountMappingRepository
.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(
productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null);
productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null);
return mapping != null ? mapping.getGlAccount() : null;
}
@@ -28,6 +28,7 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
@@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
@@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail;
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
@@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
private final WorkingDaysRepositoryWrapper workingDaysRepository;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -1003,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
// Reinvest Principal Only now renews in place on this same account (see
// DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually
// set any more, but this guard is left as a harmless no-op rather than removed, in case a future
// closure type reintroduces a genuine close-and-reopen.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
@@ -1531,11 +1537,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
// handle maturity instructions
// An account opened WITH an explicit maturity instruction (Transfer to Savings or either
// Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the
// renewal grace period never delays this, no matter what the product has configured. Grace
// only matters for an account that was opened with NO instruction at all: that one is never
// touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a
// null closure type), so it just sits matured until a human renews it from the FD Dashboard
// -- see renewMaturedFixedDeposit(), which is where the grace window is actually read.
if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
Map<String, Object> changes = new HashMap<>();
final AppUser user = context.authenticatedUser();
Long toSavingsId = fdAccount.getTransferToSavingsAccountId();
// Reinvest Principal Only needs a target for the leftover interest (see
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening
// form only collects transferToSavingsId when Transfer to Savings itself was chosen, so
// an account opened straight into Reinvest Principal Only can reach here with none set.
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = resolveLinkedAccountId(fdAccount.getId());
}
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
@@ -1546,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// would treat it as new and post it a second time without this.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
// Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never
// actually set any more, but this guard is left as a harmless no-op rather than removed,
// in case a future closure type reintroduces a genuine close-and-reopen.
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
@@ -1578,6 +1596,269 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
}
@Transactional
@Override
public Map<String, Object> renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) {
final AppUser user = context.authenticatedUser();
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
if (!fdAccount.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured",
"This FD has not matured yet -- nothing to renew.");
}
// The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE
// FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because
// what happens at maturity is now decided here, by staff, at the moment of renewal.
final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType);
if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required",
"Choose Reinvest Principal + Interest or Reinvest Principal Only.");
}
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
// should always be present, but fail clearly rather than silently stranding the interest if not.
final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId());
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
+ "so the leftover interest has somewhere to go.");
}
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays);
final boolean withinGrace = !today.isAfter(graceDeadline);
final Map<String, Object> result = new HashMap<>();
BigDecimal gapInterestAmount = null;
if (!withinGrace) {
// Late: the backdated renewal is no longer offered. The gap between the original maturity
// date and today is compensated at the linked Savings account's own product rate (simple
// interest for that stretch) before the FD renews from today, so the new term's principal
// already includes it.
gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today);
}
final LocalDate renewalDate = withinGrace ? maturityDate : today;
final Map<String, Object> changes = new HashMap<>();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType,
chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null,
withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate",
changes);
markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds);
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
// Both reinvest types now renew this same account in place (see
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never
// actually set any more, but this guard is left as a harmless no-op rather than removed, in case
// a future closure type reintroduces a genuine close-and-reopen.
FixedDepositAccount renewedAccount = fdAccount;
if (changes.get("reinvestedDepositId") != null) {
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
DepositAccountType.FIXED_DEPOSIT);
}
result.put("renewedAccountId", renewedAccount.getId());
result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null);
result.put("maturityAmount", renewedAccount.maturityAmount());
result.put("backdated", withinGrace);
if (gapInterestAmount != null) {
result.put("gapInterestAmount", gapInterestAmount);
}
return result;
}
@Transactional
@Override
public Map<String, Object> creditGapInterestIfPastGrace(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
if (!fdAccount.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured",
"This FD has not matured yet.");
}
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount)));
final Map<String, Object> result = new HashMap<>();
result.put("withinGrace", withinGrace);
result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today));
return result;
}
/**
* Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
* dialog before the staff member confirms anything -- the actual current balance, the linked
* account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid
* onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so
* projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews.
* Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets
* zero for the gap fields since nothing is owed.
*/
@Transactional(readOnly = true)
@Override
public Map<String, Object> previewFdRenewal(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
final Map<String, Object> result = new HashMap<>();
if (!fdAccount.isMatured()) {
result.put("isMatured", false);
return result;
}
result.put("isMatured", true);
final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays));
final BigDecimal currentBalance = fdAccount.getAccountBalance();
result.put("maturityDate", maturityDate.toString());
result.put("renewalGracePeriodDays", renewalGracePeriodDays);
result.put("withinGrace", withinGrace);
result.put("currentBalance", currentBalance);
if (withinGrace) {
result.put("gapDays", 0L);
result.put("gapInterestAmount", BigDecimal.ZERO);
result.put("projectedBalance", currentBalance);
return result;
}
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
result.put("gapDays", gapDays);
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
result.put("linkedAccountId", linkedAccountId);
if (linkedAccountId == null) {
// No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking
// "link a Savings account first" message rather than a number, same as the posting path does.
result.put("gapInterestAmount", null);
result.put("projectedBalance", null);
return result;
}
result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId));
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
result.put("linkedAccountRate", linkedAccountRate);
result.put("gapInterestAmount", gapInterestAmount);
// Paid onto the linked savings account, not this FD -- what renews is still just the FD's own
// current balance.
result.put("projectedBalance", currentBalance);
return result;
}
/** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */
private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) {
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays;
}
/**
* The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's
* {@code getLinkedAccountId()} -- an account whose link was set at creation through the real
* account-opening command reads back correctly there, but this call site kept returning null for
* an account confirmed to have {@code linked_account_id} set in the database, which every one of
* this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps
* whatever staleness that entity accessor has, rather than trying to chase it down across every
* caller that touches this account within the same request.
*/
private Long resolveLinkedAccountId(final Long savingsAccountId) {
return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
}
private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) {
return this.jdbcTemplate.queryForObject("""
SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", BigDecimal.class, linkedAccountId);
}
/** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} --
* shown in the Renewal Preview so staff can see exactly which account the gap interest is about
* to land on, not just that "a linked account" exists. */
private String resolveLinkedAccountNo(final Long linkedAccountId) {
return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId);
}
/** Simple interest on the FD's own current balance, for the stretch between its maturity date and
* today, at the linked account's product rate -- the pure calculation, shared by the posting path
* ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number
* shown to staff before they confirm is exactly the number that gets credited. */
private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate,
final long gapDays) {
final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365
: fdAccount.getInterestCalculationDaysInYearType();
final MathContext mc = MoneyHelper.getMathContext();
return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc)
.divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP);
}
/**
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
* linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine
* INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in
* the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at
* the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding
* it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings
* interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay
* it out through the FD's own closure math instead of it already being where it belongs. The FD's
* own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or
* the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap
* interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit}
* and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as
* a pre-step before submitting the same generic `payment` command the account's own Account Actions
* menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial
* Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint
* originally supported.
*/
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
if (linkedAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it "
+ "before renewing late, so the gap-period interest has somewhere to be paid.");
}
final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT);
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount,
linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true);
linkedAccount.addTransaction(gapInterestTxn);
linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount);
postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
}
return gapInterestAmount;
}
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(account.findExistingTransactionIds());
@@ -309,6 +309,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, ");
sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, ");
sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, ");
sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, ");
sqlBuilder.append("sp.customer_segment as customerSegment, ");
sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, ");
@@ -385,6 +386,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
// CBS Custom Fields
result.setAccountType(rs.getString("accountType"));
result.setFdType(rs.getString("fdType"));
result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays"));
result.setProductCode(rs.getString("productCode"));
result.setCustomerSegment(rs.getString("customerSegment"));
result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId"));
@@ -60,13 +60,22 @@ public final class DormancyClock {
* "last live" date — a date that has nothing to do with this account, which has never been live
* at all.
*/
public static final String LAST_LIVE_ON = """
CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE
GREATEST(
sa.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
sa.activatedon_date))
END
""";
public static final String LAST_LIVE_ON = lastLiveOn("sa");
/**
* The same rule over any account table aliased {@code alias} that has {@code activatedon_date} and
* {@code client_id} — a current account ({@code m_current_account}) is measured exactly as a savings
* account is.
*/
public static String lastLiveOn(final String alias) {
return """
CASE WHEN %1$s.activatedon_date IS NULL THEN NULL ELSE
GREATEST(
%1$s.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = %1$s.client_id AND k.verified_on IS NOT NULL),
%1$s.activatedon_date))
END
""".formatted(alias);
}
}
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -63,6 +64,26 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
this.context.authenticatedUser();
// Savings and current accounts climb the same ladder (current ones on their own current product's days), so
// the report lists both, longest idle first across the two.
final List<DormantAccountData> accounts = new ArrayList<>(retrieve(subStatus, officeName, false));
accounts.addAll(retrieve(subStatus, officeName, true));
accounts.sort(Comparator.comparing(DormantAccountData::getDaysIdle, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(DormantAccountData::getBalance, Comparator.nullsLast(Comparator.reverseOrder())));
return accounts;
}
/**
* One kind of account. m_current_account and m_current_product carry the same column names as their savings
* counterparts, so the one query serves both with the tables swapped under the same aliases. DEAF remittances are
* recorded against savings accounts only, so a current account's DEAF columns come back null.
*/
private List<DormantAccountData> retrieve(final Integer subStatus, final String officeName, final boolean current) {
final String accountTable = current ? "m_current_account" : "m_savings_account";
final String productTable = current ? "m_current_product" : "m_savings_product";
final String deafJoin = current ? "left join m_deaf_remittance dr on 1 = 0 "
: "left join m_deaf_remittance dr on dr.savings_account_id = sa.id ";
final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON;
final StringBuilder sql = new StringBuilder();
@@ -76,13 +97,13 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
sql.append("dr.refunded_on as deafRefundedOn, ");
sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, ");
sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle ");
sql.append("from m_savings_account sa ");
sql.append("from ").append(accountTable).append(" sa ");
sql.append("inner join m_client c on c.id = sa.client_id ");
sql.append("inner join m_office o on o.id = c.office_id ");
sql.append("inner join m_savings_product sp on sp.id = sa.product_id ");
sql.append("inner join ").append(productTable).append(" sp on sp.id = sa.product_id ");
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
// and the absence of one is exactly what the report has to show.
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
sql.append(deafJoin);
// An account belongs on these reports once the ladder has stamped it, or once the days
// have earned it — whichever comes first.
//
@@ -143,11 +164,17 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
// Longest idle first: those are the accounts nearest their next mark.
sql.append("order by daysIdle desc, sa.account_balance_derived desc");
return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray());
return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(current ? "CURRENT" : "SAVINGS"), params.toArray());
}
private static final class DormantAccountMapper implements RowMapper<DormantAccountData> {
private final String accountType;
DormantAccountMapper(final String accountType) {
this.accountType = accountType;
}
@Override
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final BigDecimal balance = rs.getBigDecimal("balance");
@@ -164,7 +191,8 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
final int stamped = rs.getInt("subStatus");
final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle);
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
return DormantAccountData.builder().accountType(accountType).accountId(rs.getLong("accountId"))
.accountNo(rs.getString("accountNo"))
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
.officeName(rs.getString("officeName")).productName(rs.getString("productName"))
// A null balance reads as zero rather than as missing data — an escheated account
@@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -402,8 +410,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
msg = "Savings product with short name `" + shortName + "` already exists";
param = "shortName";
msgArgs = new Object[] { shortName, dae };
} else if (checkEx.getMessage().contains("numeric field overflow")) {
msgCode += ".amount.too.large";
msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again.";
msgArgs = new Object[] { dae };
} else {
msgCode += ".unknown.data.integrity.issue";
msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage();
msgArgs = new Object[] { dae };
}
log.error("Error occured.", dae);
@@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -297,7 +297,7 @@ public class SavingsConfiguration {
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
WorkingDaysRepositoryWrapper workingDaysRepository,
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
DenominationBreakdownWriter denominationBreakdownWriter
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
) {
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
@@ -307,7 +307,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
denominationBreakdownWriter);
denominationBreakdownWriter, jdbcTemplate);
}
@Bean
@@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -34,23 +34,19 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/sponsorbanks")
@Component
@@ -59,8 +55,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
public class SponsorBankApiResource {
private final SponsorBankRepository sponsorBankRepository;
private final PlatformSecurityContext context;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */
@@ -125,78 +121,19 @@ public class SponsorBankApiResource {
return names;
}
/**
* Rejects a payable account that cannot serve as one.
*
* A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be
* a liability account, and it has to accept postings. Catching that here means an administrator
* is told while choosing the account, rather than a clerk discovering it when a draft fails.
*/
private void validateDdPayableGlAccount(final Long glAccountId) {
if (glAccountId == null) {
return;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?",
glAccountId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("The selected DD Payable GL account does not exist.");
}
final Map<String, Object> gl = rows.get(0);
final String label = gl.get("gl_code") + " · " + gl.get("name");
// 2 is LIABILITY in Fineract's GLAccountType enum.
if (((Number) gl.get("classification_enum")).intValue() != 2) {
throw new IllegalArgumentException(
"DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. "
+ label + " is not a liability account.");
}
// 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header.
if (((Number) gl.get("account_usage")).intValue() == 2) {
throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under "
+ label + ".");
}
// Read through both shapes: the column comes back as a Boolean on some drivers and as a
// 0/1 Number on others, and a plain Boolean cast would throw on the latter.
final Object disabled = gl.get("disabled");
final boolean isDisabled = disabled instanceof Boolean b ? b
: disabled instanceof Number n && n.intValue() != 0;
if (isDisabled) {
throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled.");
}
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.")
public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) {
// The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via
// a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see
// CreateSponsorBankCommandHandler. safely() is kept around the call so a validation failure
// still comes back as the same 400 it always has, rather than an unmapped 500.
return safely(() -> {
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Sponsor Bank Name is required.");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
final AppUser currentUser = this.context.authenticatedUser();
// Set field by field rather than through the all-args constructor: that constructor is
// positional, so adding a column silently shifts every argument after it, and the shift
// type-checks whenever two neighbours share a type.
final SponsorBank sponsorBank = new SponsorBank();
sponsorBank.setName(request.getName().trim());
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
sponsorBank.setCreatedBy(currentUser);
sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSponsorBank()
.withJson(this.gson.toJson(request)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
});
}
@@ -214,27 +151,14 @@ public class SponsorBankApiResource {
@Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.")
public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId,
final SponsorBankRequest request) {
// The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via
// a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see
// UpdateSponsorBankCommandHandler. safely() is kept around the call so a validation failure
// still comes back as the same 400 it always has, rather than an unmapped 500.
return safely(() -> {
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
if (request.getName() != null && !request.getName().isBlank()) {
sponsorBank.setName(request.getName().trim());
}
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorBank(sponsorBankId)
.withJson(this.gson.toJson(request)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
});
}
@@ -41,6 +41,9 @@ import java.util.Collection;
import java.util.List;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData;
@@ -76,6 +79,7 @@ public class SponsorDDApiResource {
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final PlatformSecurityContext context;
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final Gson gson = new GsonBuilder().serializeNulls().create();
@@ -539,16 +543,28 @@ public class SponsorDDApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.")
public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) {
return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping(
request.getSponsorBankId(),
request.getDdPayableGlAccountId(),
request.getChequeClearingGlAccountId(),
request.getCashGlAccountId(),
request.getChargesIncomeGlAccountId(),
request.getCashSuspenseGlAccountId(),
request.getGstPercentage(),
request.getGstPayableGlAccountId(),
request.getDdLimitAmount()));
// Routed through the generic command-source pipeline (CommandWrapperBuilder ->
// logCommandSource() -> UpdateSponsorDdGlMappingCommandHandler) so this genuinely gains
// Maker-Checker support; the write itself (issueRequestWritePlatformService.upsertGlMapping)
// is unchanged and now called from that handler instead of directly from here. The JSON is
// built by hand (not Gson-serializing `request` directly) purely to include a "locale" key -
// JsonCommand's BigDecimal extractor requires one, and GlMappingRequest has no such field.
return safely(() -> {
final JsonObject json = new JsonObject();
json.addProperty("sponsorBankId", request.getSponsorBankId());
json.addProperty("ddPayableGlAccountId", request.getDdPayableGlAccountId());
json.addProperty("chequeClearingGlAccountId", request.getChequeClearingGlAccountId());
json.addProperty("cashGlAccountId", request.getCashGlAccountId());
json.addProperty("chargesIncomeGlAccountId", request.getChargesIncomeGlAccountId());
json.addProperty("cashSuspenseGlAccountId", request.getCashSuspenseGlAccountId());
json.addProperty("gstPercentage", request.getGstPercentage());
json.addProperty("gstPayableGlAccountId", request.getGstPayableGlAccountId());
json.addProperty("ddLimitAmount", request.getDdLimitAmount());
json.addProperty("locale", "en");
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorDdGlMapping()
.withJson(this.gson.toJson(json)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
});
}
@POST

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