Compare commits

...
21 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
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
61 changed files with 2383 additions and 411 deletions
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); 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; break;
case ACCRUAL_PERIODIC: case ACCRUAL_PERIODIC:
// asset // asset
@@ -137,7 +137,16 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode(); String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) { 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); commandSource.markAsChecked(user);
} else { } else {
if (commandSource.isSanitized()) { if (commandSource.isSanitized()) {
@@ -151,25 +160,6 @@ public class CommandSourceService {
return result; 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) { private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return; return;
@@ -4809,4 +4809,36 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/originators/" + originatorId; this.href = "/loans/" + loanId + "/originators/" + originatorId;
return this; 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 FromJsonHelper fromApiJsonHelper;
private final CommandProcessingService processAndLogCommandService; private final CommandProcessingService processAndLogCommandService;
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService; 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; private final ConfigurationDomainService configurationService;
@Override @Override
@@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
AppUser appUser = this.context.authenticatedUser(); AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode(); String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode); appUser.validateHasCheckerPermissionTo(permissionCode);
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) { // Maker-identity rule: the user who submitted a command may never be the one who approves
AppUser maker = commandSource.getMaker(); // (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
if (maker == null) { // permission still lets its holder action a DIFFERENT user's request without needing the
throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); // 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
if (Objects.equals(appUser.getId(), maker.getId())) { // global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); // 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; return commandSource;
} }
@@ -73,6 +73,7 @@ public enum JobName {
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), // CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), // AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), // CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
; // ; //
private final String name; private final String name;
@@ -55,17 +55,25 @@ public enum PortfolioProductType {
return null; 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) { switch (v) {
case 1: case 1:
return LOAN; return LOAN;
case 2: case 2:
return SAVING; return SAVING;
case 3: case 3:
return CLIENT;
case 4:
return PROVISIONING; return PROVISIONING;
case 5: case 4:
return SHARES; return SHARES;
case 5:
return CLIENT;
case 6:
return CURRENT;
default: default:
return null; return null;
} }
@@ -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") @Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId); 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); long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
} }
@@ -651,6 +651,8 @@ public class AccountingProcessorHelper {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId);
// The payable comes from the Interest Payable row on the product's Accounting tab, which is // The payable comes from the Interest Payable row on the product's Accounting tab, which is
// where a bank actually picks it. The reserved name in the product's other GL mappings is // where a bank actually picks it. The reserved name in the product's other GL mappings is
@@ -664,13 +666,20 @@ public class AccountingProcessorHelper {
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
.orElse(payableAccount); .orElse(payableAccount);
// The interest expense is not raised here. It is recognised as the interest accrues, day by // Stage 1: the expense is recognised against whatever the liability sits in right now -- the
// day, and this posting settles that recognised debt - raising the expense again at payout // named Payable account if the product has one, or straight against Savings Control if it
// would put the same interest on the P&L twice. This mirrors the pooled posting service, // doesn't. This was missing entirely: every product, configured or not, posted zero journal
// which pays a product's whole membership the same way; a closure reaching this path must // entries for interest until this leg existed, since a product with no Payable/Paid names
// produce the same entries as the nightly run, or the two disagree on what one payment did. // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
// to post in their place.
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// The interest expense is not raised again here. It was just raised above, and this posting
// only settles that recognised debt onward to where it is actually paid out from -- raising
// the expense a second time would put the same interest on the P&L twice.
// //
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the // Stage 2: the liability moves from the payable to the account it is paid out of. Where the
// product names no paid account both resolve to the payable and this says nothing, so it is // product names no paid account both resolve to the payable and this says nothing, so it is
// skipped and the payout comes straight out of the payable. // skipped and the payout comes straight out of the payable.
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
@@ -679,7 +688,7 @@ public class AccountingProcessorHelper {
isReversal); isReversal);
} }
// Stage 2: into the member's own balance. // Stage 3: into the member's own balance.
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal); isReversal);
@@ -105,17 +105,22 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .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(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ") .append(" eao.external_id as externalAssetOwner, ")
// entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and // entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
// current-account entries -- current products deliberately share SAVING's GL type // m_current_account -- newer write paths tag current-account entries with it
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two // instead of reusing SAVING's type, specifically so this case needs no guessing.
// apart on its own, and m_savings_account/m_current_account each have their own id //
// sequence: an entity_id can be a real row in both, making a plain // entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
// COALESCE(ca.account_no, sa.account_no) pick the current account's number even for // and current-account entries by older write paths not yet updated to the above --
// an entry that is genuinely a savings one. savings_transaction_id is the one // current products used to deliberately share SAVING's GL type space (see
// unambiguous signal available here -- only a real savings transaction sets it -- so // PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
// it decides first; only when it's absent (a current-account entry, or a savings // m_current_account each have their own id sequence, so an entity_id can be a real
// entry with no linked transaction row) does the current-account guess apply. // 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(" 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 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(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" ELSE NULL ") .append(" ELSE NULL ")
@@ -141,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_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_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_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()) { if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") 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 ") .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; break;
case CASH_BASED: case CASH_BASED:
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element); 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; break;
case ACCRUAL_PERIODIC: 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.cob.service.LoanCOBCatchUpService;
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException; import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
import org.apache.fineract.infrastructure.jobs.service.JobName; import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/loans") @Path("/v1/loans")
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
public class LoanCOBCatchUpApiResource { public class LoanCOBCatchUpApiResource {
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp; private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
private final PlatformSecurityContext context;
@GET @GET
@Path("oldest-cob-closed") @Path("oldest-cob-closed")
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
@ApiResponse(responseCode = "202", description = "Catch Up has been started") @ApiResponse(responseCode = "202", description = "Catch Up has been started")
@ApiResponse(responseCode = "400", description = "Catch Up is already running") @ApiResponse(responseCode = "400", description = "Catch Up is already running")
public Response executeLoanCOBCatchUp() { public Response executeLoanCOBCatchUp() {
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> { return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) { if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
return Response.status(Response.Status.BAD_REQUEST).build(); return Response.status(Response.Status.BAD_REQUEST).build();
@@ -165,9 +165,11 @@ public class SecurityConfig {
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this // 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 // with an X-Api-Key header instead of a Fineract username/password. The key itself
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the // is issued by inserting directly into m_api_client in the tenant-store database
// outer gate stays open for this one narrow GET path, and // (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 // ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and // 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, // IP allowlist) — a request with no key and no Fineract login still gets nothing,
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DELETE;
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates. * 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 * List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted * (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
* 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. * {@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") @Path("/v1/apiclients")
@Component @Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService; private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@POST @POST
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public String create(final String body) { public String create(final String body) {
final AppUser user = requireSuperUser(); requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body); throw new WebApplicationException(Response.status(403)
final String clientName = fromJsonHelper.extractStringNamed("clientName", root); .entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root); + "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
} }
@GET @GET
@@ -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 * 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. * 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 = """ private static final String RESOLVE_SQL = """
SELECT je.amount AS amount, SELECT je.amount AS amount,
je.currency_code AS currencyCode, je.currency_code AS currencyCode,
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
je.loan_transaction_id AS loanTxnId, je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType, je.entity_type_enum AS entityType,
COALESCE( COALESCE(
esa.client_id, CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
eca.client_id, 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, eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END, CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id, sa.client_id,
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
) AS clientId ) AS clientId
FROM acc_gl_journal_entry je 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_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_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_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_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(""" final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.REAL_TXN_ID + """ AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL 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 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) WHERE au.id = std.officer_id)
@@ -211,7 +211,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.REAL_TXN_ID + """ AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL 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 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) WHERE au.id = std.officer_id)
@@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed. // reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) { if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId, 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)); return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource {
updatedLog, Timestamp.from(Instant.now()), id 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 // Keep the central voucher register in sync with the edited amount
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount); voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
@@ -1004,9 +1015,14 @@ public class CashierDepositRequestApiResource {
*/ */
// Called at submit time — uses request id as temporary txn_id. // Called at submit time — uses request id as temporary txn_id.
// approve() updates txn_id to real Fineract id; reject() deletes these records. // 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, private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo, 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; final JsonObject denomMap;
try { try {
denomMap = gson.fromJson(denominationData, JsonObject.class); denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -1037,10 +1053,14 @@ public class CashierDepositRequestApiResource {
INSERT INTO m_savings_txn_denomination INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id, (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) 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, requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count), 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 cashierId, cashierUsername, paymentTypeName
); );
} }
@@ -90,7 +90,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' " + "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) " + "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID + "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -120,7 +120,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' " + "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) " + "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID + "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -60,34 +60,11 @@ public class CashReportApiResource {
* </ul> * </ul>
* *
* <p> * <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared * The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer * shared with the day register, the reconciliation screen and the withdrawal stock check, so a
* reads the same on all four. * drawer reads the same on all four.
*/ */
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
// such transactions in this database were carrying ₹1.68 lakh that never existed.
//
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
// everything counted after it) out of the cashier's position.
//
// Compared per transaction inside a correlated subquery rather than as a join, so a
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
/** /**
* Day book — every voucher raised on one date, in one list, newest first. * Day book — every voucher raised on one date, in one list, newest first.
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.office.domain.Office; 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.charge.domain.Charge;
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType; import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; 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, postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId); accountId, accountType);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId); chequeBookId);
@@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService {
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ /** 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, 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 JsonArray debitArr = new JsonArray();
final JsonObject debit = new JsonObject(); final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", debitGlId); 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. // link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
final String glTxnId = result.getTransactionId(); final String glTxnId = result.getTransactionId();
if (glTxnId != null) { 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 // 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 // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry. // stale, unrelated journal entry.
@@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService {
+ " ORDER BY id DESC LIMIT 1", Long.class, + " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId); glTxnId);
if (!rowIds.isEmpty()) { if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, final int entityType = accountType == ChequeAccountType.CURRENT
rowIds.get(0)); ? 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));
} }
} }
} }
@@ -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.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; 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.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -118,7 +119,7 @@ public class ClearingJournalService {
} }
final String glTxnId = result.getTransactionId(); final String glTxnId = result.getTransactionId();
if (glTxnId != null && linkAccountId != null) { if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId); linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType);
} }
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
return glTxnId; return glTxnId;
@@ -191,7 +192,13 @@ public class ClearingJournalService {
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); 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 // 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 // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry. // stale, unrelated journal entry.
@@ -200,8 +207,11 @@ public class ClearingJournalService {
+ " ORDER BY id DESC LIMIT 1", Long.class, + " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId); glTxnId);
if (!rowIds.isEmpty()) { if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, final int entityType = "CURRENT".equalsIgnoreCase(accountType)
rowIds.get(0)); ? 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.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
@@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource {
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId)); 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); btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
@@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId)); 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)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
} }
@@ -630,7 +631,15 @@ public class ClerkDisbursementRequestApiResource {
return ps; return ps;
}, btLogKh); }, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue(); 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); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
} }
@@ -681,7 +690,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser, description); accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(directCaTxnId)); txnRefs.add(String.valueOf(directCaTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt); 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)); undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
} else { } else {
// Real CBS book transfer: direct credit to savings account. // Real CBS book transfer: direct credit to savings account.
@@ -696,7 +705,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser); accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(directTxnId)); txnRefs.add(String.valueOf(directTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt); 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)); undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
} }
} else { } else {
@@ -715,7 +724,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(debitTxnId)); txnRefs.add(String.valueOf(debitTxnId));
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount));
if (isCurrentAccount) { if (isCurrentAccount) {
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
} else { } else {
@@ -979,7 +988,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId)); 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) { if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else { } else {
@@ -1005,7 +1014,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId)); 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) { if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else { } else {
@@ -1568,20 +1577,27 @@ public class ClerkDisbursementRequestApiResource {
final BigDecimal amount; final BigDecimal amount;
final String accountRef; // savings account number shown in GL comments; null for source/target GL 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 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, GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) { 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, GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId, final Long savingsAccountId) { 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.glAccountId = glAccountId;
this.amount = amount; this.amount = amount;
this.accountRef = accountRef; this.accountRef = accountRef;
this.txnId = txnId; this.txnId = txnId;
this.savingsAccountId = savingsAccountId; this.savingsAccountId = savingsAccountId;
this.isCurrentAccount = isCurrentAccount;
} }
} }
@@ -1603,8 +1619,16 @@ public class ClerkDisbursementRequestApiResource {
/** /**
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) * 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). * 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, private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) { final List<GlEntry> entries, final int typeEnum) {
@@ -1618,11 +1642,15 @@ public class ClerkDisbursementRequestApiResource {
Long.class, glTxnId, typeEnum, entries.size()); Long.class, glTxnId, typeEnum, entries.size());
java.util.Collections.reverse(rowIds); java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) { 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) { if (savingsAccId != null) {
final int entityType = entry.isCurrentAccount
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?",
savingsAccId, rowIds.get(i)); entityType, savingsAccId, rowIds.get(i));
} }
} }
} }
@@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource {
INSERT INTO m_savings_txn_denomination INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id, (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) 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, txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count), 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 cashier.getId(), cashier.getUsername(), paymentTypeName
); );
} }
@@ -87,4 +87,53 @@ public final class CountedCashLedger {
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN" + " 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 + " 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 + ") "; + " 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)" + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'" + " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID + " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -310,7 +310,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'" + " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID + " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -407,7 +407,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'" + " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID + " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -431,7 +431,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'" + " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID + " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -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);
}
}
@@ -228,13 +228,18 @@ public class RbiReserveConfigApiResource {
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND"); 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. */ /** 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", "MEMO"); 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<>(); private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
static { static {
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head"); 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("ASSET_NET", "Netted off the base to arrive at NDTL");
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement"); 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"); USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
} }
@@ -117,6 +117,13 @@ public class RbiReserveReturnApiResource {
private static final String USAGE_LIABILITY = "LIABILITY"; private static final String USAGE_LIABILITY = "LIABILITY";
private static final String USAGE_ASSET_NET = "ASSET_NET"; private static final String USAGE_ASSET_NET = "ASSET_NET";
private static final String USAGE_MAINTAINED = "MAINTAINED"; 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 JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
@@ -256,21 +263,29 @@ public class RbiReserveReturnApiResource {
// average across the fortnight AND a daily minimum on every single day of it, so a bank // 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 // that held nothing for thirteen days and topped up on the last one fails even though
// its closing position looks ample. // its closing position looks ample.
final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart")); // The maintenance table covers the whole calendar month, not the fortnight alone: a
final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd")); // 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")); final List<String> maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS"));
maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED)); maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED));
// Only up to the reporting date: days after it have not happened, and averaging an // 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. // unfinished fortnight over its whole length would understate what is held so far.
final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd; final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd;
final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles); final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles,
required);
final BigDecimal average = (BigDecimal) daily.get("average"); final BigDecimal average = (BigDecimal) daily.get("average");
final BigDecimal lowest = (BigDecimal) daily.get("lowest"); final BigDecimal lowest = (BigDecimal) daily.get("lowest");
final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2, final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2,
RoundingMode.HALF_UP); 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("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
daily.put("requiredAverage", required); daily.put("requiredAverage", required);
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT); daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
@@ -398,31 +413,49 @@ public class RbiReserveReturnApiResource {
} }
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd)); lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
lines.add(line("SLR-eligible investments (government securities)", investments, 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. // Shown for completeness of the month-end liquid position; not added into SLR maintained.
// A custom MAINTAINED head IS added: it was declared as counting toward the requirement,
// and Form 9's requirement is the SLR one.
lines.add(line("Cash in hand (memo)", cash, monthEnd)); lines.add(line("Cash in hand (memo)", cash, monthEnd));
for (final CustomHead head : heads) { for (final CustomHead head : heads) {
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { if (USAGE_LIABILITY.equals(head.usage) || USAGE_ASSET_NET.equals(head.usage)
// Every such head is a memo on THIS return, including one marked MAINTAINED: only || USAGE_SLR_ASSET.equals(head.usage) || inNamedSection(head)) {
// SLR-eligible investments count toward SLR, so the label must not suggest the continue;
// figure was added into the maintained total when it was not.
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
} }
// 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); response.put("lines", lines);
final boolean computable = rate != null && dtl.configured && investments.configured; // 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); response.put("computable", computable);
if (computable) { if (computable) {
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
// SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT // A head marked MAINTAINED is deliberately NOT added: that usage means CRR, which is met
// added here the way it is on Form 1: it was declared as counting toward a requirement, // in cash and balances with banks. Letting it count here would allow a head configured
// and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head // for Form 1 to inflate the SLR position - exactly the error this return exists to
// configured for CRR inflate the SLR position, which is the error this return exists to // detect. Only SLR_ASSET heads and the built-in investments role count.
// detect. A bank with more eligible securities maps them to SLR_INVESTMENTS. final BigDecimal held = investments.amount.add(slrHeads);
final BigDecimal held = investments.amount;
response.put("dtlAmount", liabilities); response.put("dtlAmount", liabilities);
response.put("demandLiabilities", dtl.configured ? demand : null); response.put("demandLiabilities", dtl.configured ? demand : null);
response.put("timeLiabilities", dtl.configured ? time : null); response.put("timeLiabilities", dtl.configured ? time : null);
@@ -434,7 +467,11 @@ public class RbiReserveReturnApiResource {
response.put("compliant", held.compareTo(required) >= 0); response.put("compliant", held.compareTo(required) >= 0);
response.put("maintainedPercentage", percentageOf(held, ndtl)); response.put("maintainedPercentage", percentageOf(held, ndtl));
} }
response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments)); // 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); return gson.toJson(response);
} }
@@ -645,6 +682,11 @@ public class RbiReserveReturnApiResource {
} }
/** The total a set of custom heads contributes under one treatment. */ /** 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) { private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
BigDecimal total = BigDecimal.ZERO; BigDecimal total = BigDecimal.ZERO;
for (final CustomHead head : heads) { for (final CustomHead head : heads) {
@@ -753,7 +795,7 @@ public class RbiReserveReturnApiResource {
* excluding those days would let a fortnight ending on a long weekend average away a shortfall. * 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, private Map<String, Object> dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId,
final List<String> maintainedRoles) { final List<String> maintainedRoles, final BigDecimal required) {
final List<Map<String, Object>> days = new ArrayList<>(); final List<Map<String, Object>> days = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO; BigDecimal total = BigDecimal.ZERO;
BigDecimal lowest = null; BigDecimal lowest = null;
@@ -771,7 +813,11 @@ public class RbiReserveReturnApiResource {
} }
final Map<String, Object> row = new LinkedHashMap<>(); final Map<String, Object> row = new LinkedHashMap<>();
row.put("date", day.toString()); 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("amount", held);
row.put("surplus", held.subtract(required));
days.add(row); days.add(row);
} }
@@ -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);
}
}
@@ -33,8 +33,12 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder * FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the
* needs (account/client/product identity, maturity date, deposit and maturity amounts). * 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 * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
@@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource {
private static final int FIXED_DEPOSIT = 200; private static final int FIXED_DEPOSIT = 200;
/** SavingsAccountStatusType.ACTIVE. */ /** SavingsAccountStatusType.ACTIVE. */
private static final int ACTIVE = 300; 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 PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource {
JOIN m_savings_product sp ON sp.id = sa.product_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_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum = ? WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?)
ORDER BY datp.maturity_date ASC ORDER BY datp.maturity_date ASC
""", FIXED_DEPOSIT, ACTIVE); """, FIXED_DEPOSIT, ACTIVE, MATURED);
return this.gson.toJson(rows); return this.gson.toJson(rows);
} }
@@ -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);
}
}
@@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate); account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); 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 // 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 // the deposit rolls over -- Reinvest Principal Only renews just the closing principal here
// term's principal, so there is no withdrawal, no deposit and no journal entry for the // (below), with only the leftover interest actually leaving, rather than the legacy
// rollover itself -- which also removes the transfer-suspense round trip the old // close-and-reopen this branch used to use, which left the customer with a second account
// close-and-reopen flow needed. // 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 closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal); : 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 // 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 // Interest Rates screen shows and the account-opening wizard prices from. Used for both the
@@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer closingTenure = account.depositPeriod(); final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth); financialYearBeginningMonth);
this.savingsAccountRepository.save(account); 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, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum); closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo()); changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) { // Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is
// "Re-invest principal only" still closes and reopens, because only the principal carries // zero (renewedAmount already equals maturedBalance), so this stays a no-op for it.
// 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();
// Whatever is left after the principal moves into the new deposit is interest the customer
// earned and has to actually be paid out somewhere -- it must not just sit on this account,
// which is about to be closed. 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(reInvestAmount);
if (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.");
}
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);
// Pay out whatever didn't move into the new deposit, then actually close this account --
// previously this branch left the leftover interest stranded on an account that was never
// closed, silently, because nothing past this point ever referenced toSavingsId at all.
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT); DepositAccountType.SAVINGS_DEPOSIT);
@@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account); updateAlreadyPostedTransactions(existingTransactionIds, account);
} }
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
} else if (onClosureType.isTransferToSavings()) { } else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
@@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); 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.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured(); closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow); 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. // The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
@@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close * 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 * (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 * -- 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 * transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what * Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just
* the existing manual Close command still uses today. * 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> * <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- * 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 * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
@@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and // These three branches keep the account open, so they never reach close()/prematureClosure()
// would otherwise accept any date at all -- including one behind already-posted interest, which // and would otherwise accept any date at all -- including one behind already-posted interest,
// the renewal would then turn into a pivot that reverses and re-posts it. // which the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate); account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount(); final BigDecimal closingPrincipal = account.getDepositAmount();
@@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", 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); "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 { } else {
renewedAmount = maturedBalance; renewedAmount = maturedBalance;
} }
@@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo()); 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 // 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 // term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is
// way the Transfer to Savings branch below posts its full-balance transfer. // 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 BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); if (remainder.compareTo(BigDecimal.ZERO) > 0) {
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
DepositAccountType.SAVINGS_DEPOSIT); final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
final boolean isExceptionForBalanceCheck = false; DepositAccountType.SAVINGS_DEPOSIT);
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS, final boolean isExceptionForBalanceCheck = false;
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null, final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
isAccountTransfer, isExceptionForBalanceCheck); null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
updateAlreadyPostedTransactions(existingTransactionIds, account); this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
} updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
} }
} else if (onClosureType.isTransferToSavings()) { } else if (onClosureType.isTransferToSavings()) {
@@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); .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); throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
} }
@@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); .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); throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
} }
@@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out // Reinvest Principal Only now renews in place on this same account (see
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually // DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. // 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) { if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
@@ -1554,7 +1555,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// an account opened straight into Reinvest Principal Only can reach here with none set. // 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. // Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = fdAccount.getLinkedAccountId(); toSavingsId = resolveLinkedAccountId(fdAccount.getId());
} }
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
@@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// would treat it as new and post it a second time without this. // would treat it as new and post it a second time without this.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); 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) { if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); .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 amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount); Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) { if (activationChargeAmount.isGreaterThanZero()) {
@@ -1620,7 +1618,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the // 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 // 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. // should always be present, but fail clearly rather than silently stranding the interest if not.
final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId(); final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId());
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", 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, " "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
@@ -1630,13 +1628,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition // 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 // 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. // window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
Integer renewalGracePeriodDays = null; final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
renewalGracePeriodDays = 0;
}
final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate(); final LocalDate today = DateUtils.getBusinessLocalDate();
@@ -1669,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
// REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId); // Both reinvest types now renew this same account in place (see
// REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never
// actually carries the new term's maturity date/amount either way. // 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; FixedDepositAccount renewedAccount = fdAccount;
if (changes.get("reinvestedDepositId") != null) { if (changes.get("reinvestedDepositId") != null) {
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
@@ -1697,17 +1690,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
"This FD has not matured yet."); "This FD has not matured yet.");
} }
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
renewalGracePeriodDays = 0;
}
final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate(); final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount)));
final Map<String, Object> result = new HashMap<>(); final Map<String, Object> result = new HashMap<>();
result.put("withinGrace", withinGrace); result.put("withinGrace", withinGrace);
@@ -1716,49 +1701,160 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
} }
/** /**
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its * Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
* linked Savings/Current account's product -- credited onto the FD itself as a genuine * dialog before the staff member confirms anything -- the actual current balance, the linked
* INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the * account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid
* cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD * onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so
* Dashboard's generic Payment command) reads the account's balance. Shared by * projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews.
* {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's * Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets
* Renew action now calls the latter as a pre-step before submitting the same generic `payment` * zero for the gap fields since nothing is owed.
* 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) { @Transactional(readOnly = true)
final Long linkedAccountId = fdAccount.getLinkedAccountId(); @Override
if (linkedAccountId == null) { public Map<String, Object> previewFdRenewal(final Long fdAccountId) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " DepositAccountType.FIXED_DEPOSIT);
+ "before renewing late, so the gap-period interest has a rate to use."); final Map<String, Object> result = new HashMap<>();
if (!fdAccount.isMatured()) {
result.put("isMatured", false);
return result;
} }
final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" 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 SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ? WHERE sa.id = ?
""", BigDecimal.class, linkedAccountId); """, BigDecimal.class, linkedAccountId);
}
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); /** 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 final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365
: fdAccount.getInterestCalculationDaysInYearType(); : fdAccount.getInterestCalculationDaysInYearType();
final MathContext mc = MoneyHelper.getMathContext(); final MathContext mc = MoneyHelper.getMathContext();
final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc) return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc)
.multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc) .divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP);
.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) { if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>(); final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>(); final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(), final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount,
today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true); linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true);
fdAccount.addTransaction(gapInterestTxn); linkedAccount.addTransaction(gapInterestTxn);
fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions()); linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
} }
return gapInterestAmount; return gapInterestAmount;
} }
@@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); .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); throw new SavingsProductSelfApprovalNotAllowedException(productId);
} }
@@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); .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); throw new SavingsProductSelfApprovalNotAllowedException(productId);
} }
@@ -402,8 +410,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
msg = "Savings product with short name `" + shortName + "` already exists"; msg = "Savings product with short name `" + shortName + "` already exists";
param = "shortName"; param = "shortName";
msgArgs = new Object[] { shortName, dae }; 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 { } else {
msgCode += ".unknown.data.integrity.issue"; msgCode += ".unknown.data.integrity.issue";
msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage();
msgArgs = new Object[] { dae }; msgArgs = new Object[] { dae };
} }
log.error("Error occured.", dae); log.error("Error occured.", dae);
@@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); .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); throw new SavingsProductSelfApprovalNotAllowedException(productId);
} }
@@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); .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); throw new SavingsProductSelfApprovalNotAllowedException(productId);
} }
@@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); .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); throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
} }
@@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); .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); throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
} }
@@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); .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); throw new SavingsProductSelfApprovalNotAllowedException(productId);
} }
@@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); .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); throw new SavingsProductSelfApprovalNotAllowedException(productId);
} }
@@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); .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); throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
} }
@@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); .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); throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
} }
@@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId)); .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); throw new ShareProductSelfApprovalNotAllowedException(productId);
} }
@@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId)); .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); throw new ShareProductSelfApprovalNotAllowedException(productId);
} }
@@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
@@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final Long drGlAccountId; final Long drGlAccountId;
Long linkedAccountId = null; Long linkedAccountId = null;
boolean linkedAccountIsCurrent = false;
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null;
final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null;
@@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, currentUser.getId(), note); issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId); drGlAccountId = getCurrentControlGlId(currentAccountId);
linkedAccountId = currentAccountId; linkedAccountId = currentAccountId;
linkedAccountIsCurrent = true;
} }
if (drGlAccountId == null) { if (drGlAccountId == null) {
throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping.");
@@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String comments = "Sponsor DD issuance - DD No " + ddNumber; final String comments = "Sponsor DD issuance - DD No " + ddNumber;
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount,
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
linkedAccountIsCurrent);
final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft(
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
@@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
Long resultingCurrentTransactionId = null; Long resultingCurrentTransactionId = null;
final Long drGlAccountId; final Long drGlAccountId;
Long linkedAccountId = null; Long linkedAccountId = null;
boolean linkedAccountIsCurrent = false;
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
// Book debit, no journal entry of its own -- see approveIssueRequest(). // Book debit, no journal entry of its own -- see approveIssueRequest().
@@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, currentUser.getId(), note); issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId); drGlAccountId = getCurrentControlGlId(currentAccountId);
linkedAccountId = currentAccountId; linkedAccountId = currentAccountId;
linkedAccountIsCurrent = true;
} }
if (drGlAccountId == null) { if (drGlAccountId == null) {
throw new IllegalStateException( throw new IllegalStateException(
@@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges,
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
linkedAccountIsCurrent);
for (final Map<String, Object> request : requests) { for (final Map<String, Object> request : requests) {
this.sponsorDDWritePlatformService.issueDemandDraft( this.sponsorDDWritePlatformService.issueDemandDraft(
@@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
* liability, so they get their own credit leg rather than being folded into the liability). Resolves * liability, so they get their own credit leg rather than being folded into the liability). Resolves
* the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request /
* m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's
* savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / * savings/current account for an account-debit DD), the Dr-side row is tagged
* entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching * entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so
* how Fineract's own auto-generated entries display. * GL reports show the account number instead of a blank "—", matching how Fineract's own
* auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account
* each with their own sequence) can't resolve a same-numbered stranger's account instead.
*/ */
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
final String comments, final Long linkedAccountId) { final String comments, final Long linkedAccountId) {
return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId,
txnDate, comments, linkedAccountId); txnDate, comments, linkedAccountId, false);
} }
/** /**
@@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId,
final boolean linkedAccountIsCurrent) {
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0;
@@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly. // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
if (linkedAccountId != null) { if (linkedAccountId != null) {
// 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 -- entity_type_enum must say which one linkedAccountId
// names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit
// branch resolves either a savingsAccountId or a currentAccountId into this same parameter).
final int entityType = linkedAccountIsCurrent
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update( this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = (" "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ("
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1)", + " ORDER BY id DESC LIMIT 1)",
linkedAccountId, transactionId); entityType, linkedAccountId, transactionId);
} }
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList( final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
@@ -18,7 +18,6 @@
*/ */
package org.apache.fineract.spm.api; package org.apache.fineract.spm.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; 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.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.spm.data.SurveyData; import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey; import org.apache.fineract.spm.domain.Survey;
@@ -57,6 +60,8 @@ public class SpmApiResource {
private final PlatformSecurityContext securityContext; private final PlatformSecurityContext securityContext;
private final SpmService spmService; private final SpmService spmService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@GET @GET
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -103,12 +108,10 @@ public class SpmApiResource {
@Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n" @Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n"
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description") + "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) { public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
this.securityContext.authenticatedUser(); final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
final Survey survey = SurveyMapper.map(surveyData, new Survey()); final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
this.spmService.createSurvey(survey); return this.toApiJsonSerializer.serialize(result);
return getResponse(survey.getId());
} }
@PUT @PUT
@@ -116,12 +119,10 @@ public class SpmApiResource {
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Transactional @Transactional
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) { public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
this.securityContext.authenticatedUser(); final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
final Survey surveyToUpdate = this.spmService.findById(id); final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate); return this.toApiJsonSerializer.serialize(result);
this.spmService.updateSurvey(survey);
return getResponse(survey.getId());
} }
@POST @POST
@@ -131,22 +132,17 @@ public class SpmApiResource {
@Transactional @Transactional
@Operation(summary = "Deactivate Survey", description = "") @Operation(summary = "Deactivate Survey", description = "")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) { public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
this.securityContext.authenticatedUser(); final CommandWrapperBuilder builder = new CommandWrapperBuilder();
final CommandWrapper commandRequest;
if (command != null && command.equalsIgnoreCase("activate")) { if (command != null && command.equalsIgnoreCase("activate")) {
this.spmService.activateSurvey(id); commandRequest = builder.activateSurvey(id).build();
} else if (command != null && command.equalsIgnoreCase("deactivate")) { } else if (command != null && command.equalsIgnoreCase("deactivate")) {
this.spmService.deactivateSurvey(id); commandRequest = builder.deactivateSurvey(id).build();
} else { } else {
throw new UnrecognizedQueryParamException("command", command); throw new UnrecognizedQueryParamException("command", command);
} }
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} return this.toApiJsonSerializer.serialize(result);
private String getResponse(Long id) {
Gson gson = new Gson();
HashMap<String, Object> response = new HashMap<>();
response.put("resourceId", id);
return gson.toJson(response);
} }
} }
@@ -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.spm.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.spm.service.SpmService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "ACTIVATE")
public class ActivateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
@Autowired
public ActivateSurveyCommandHandler(final SpmService spmService) {
this.spmService = spmService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
this.spmService.activateSurvey(command.entityId());
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(command.entityId()) //
.build();
}
}
@@ -0,0 +1,71 @@
/**
* 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.spm.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
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.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
import org.apache.fineract.spm.service.SpmService;
import org.apache.fineract.spm.util.SurveyMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "CREATE")
public class CreateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
private final ObjectMapper objectMapper;
@Autowired
public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
this.spmService = spmService;
this.objectMapper = objectMapper;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SurveyData surveyData = readSurveyData(command);
final Survey survey = SurveyMapper.map(surveyData, new Survey());
this.spmService.createSurvey(survey);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(survey.getId()) //
.build();
}
private SurveyData readSurveyData(final JsonCommand command) {
try {
return this.objectMapper.readValue(command.json(), SurveyData.class);
} catch (final JsonProcessingException e) {
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
}
}
}
@@ -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.spm.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.spm.service.SpmService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "DEACTIVATE")
public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
@Autowired
public DeactivateSurveyCommandHandler(final SpmService spmService) {
this.spmService = spmService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
this.spmService.deactivateSurvey(command.entityId());
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(command.entityId()) //
.build();
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.spm.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
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.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
import org.apache.fineract.spm.service.SpmService;
import org.apache.fineract.spm.util.SurveyMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "UPDATE")
public class UpdateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
private final ObjectMapper objectMapper;
@Autowired
public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
this.spmService = spmService;
this.objectMapper = objectMapper;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SurveyData surveyData = readSurveyData(command);
final Survey surveyToUpdate = this.spmService.findById(command.entityId());
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
this.spmService.updateSurvey(survey);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(survey.getId()) //
.build();
}
private SurveyData readSurveyData(final JsonCommand command) {
try {
return this.objectMapper.readValue(command.json(), SurveyData.class);
} catch (final JsonProcessingException e) {
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
}
}
}
@@ -114,17 +114,33 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform
+ "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT'," + "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT',"
+ "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'"; + "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'";
/*
* Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline
* (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a
* different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen
* (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global
* maker-checker on/off switch and other System -> Configurations settings. If either were flipped
* to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin
* could lock themselves out of ever approving further changes to maker-checker configuration
* itself, with no ordinary way to reverse it. Excluding them here only hides them from this
* screen; it does not touch their can_maker_checker value or their normal enforcement.
*/
private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'";
public String makerCheckerablePermissionSchema() { public String makerCheckerablePermissionSchema() {
/* /*
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping, * get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
* the READ permissions and the CHECKER permissions), and exclude permission codes whose approval * the READ permissions and the CHECKER permissions), and exclude permission codes whose approval
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above). * workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as
* codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES
* above) to avoid a self-lockout.
*/ */
return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected" return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected"
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping") + " from m_permission p " + " where " + sqlGenerator.escape("grouping")
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'" + " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
+ " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")" + " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")"
+ " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")"
+ " order by p.grouping, coalesce(entity_name, ''), p.code"; + " order by p.grouping, coalesce(entity_name, ''), p.code";
} }
@@ -32,4 +32,5 @@
<include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/> <include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/>
<include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/> <include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/>
<include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/> <include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0012_seed_default_platform_api_client.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Seeds ONE fixed, well-known platform-operator API key on every deployment, so
#/platform-admin/menu-access works out of the box the first time the app is stood up,
without anyone needing shell/DB access to mint the first key by hand. It is deliberately
the SAME raw value on every deployment/tenant (requested behaviour) rather than a random
one generated per install - the trade-off, on purpose: this value is visible to anyone who
can read this source file, exactly like a default admin password shipped in a seed script.
It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely
local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's
hashing) and either deactivate or DELETE this row - POST /v1/apiclients is intentionally
disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same
as this seed.
Raw key (only shown here, since it is fixed and not a secret to rotate away from):
cbsx_default_platform_operator_key_2026
api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase - matching
ApiClientCredentialService#sha256Hex. -->
<changeSet id="0012_seed_default_platform_api_client" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_api_client"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_api_client
WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_api_client">
<column name="client_name" value="Default Platform Operator (rotate before real production use)"/>
<column name="api_key_hash" value="9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd"/>
<column name="api_key_prefix" value="cbsx_default"/>
<column name="allowed_ips"/>
<column name="created_by"/>
<column name="created_by_tenant" value="ALL"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -141,6 +141,9 @@
<include file="parts/0579_current_product_gl_mappings_own_product_type.xml" relativeToChangelogFile="true"/> <include file="parts/0579_current_product_gl_mappings_own_product_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0580_unique_core_product_gl_mapping.xml" relativeToChangelogFile="true"/> <include file="parts/0580_unique_core_product_gl_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0581_form_a_completion.xml" relativeToChangelogFile="true"/> <include file="parts/0581_form_a_completion.xml" relativeToChangelogFile="true"/>
<include file="parts/0582_form_a_saved_report.xml" relativeToChangelogFile="true"/>
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/> <include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own <!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it 0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
@@ -149,4 +152,9 @@
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
name and this changeset's id were updated to match. --> name and this changeset's id were updated to match. -->
<include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/> <include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0584_add_manage_survey_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Lets a generated Form A be kept, so a filed return can be looked at again later exactly as it
was filed.
The summary columns 0581 added are enough to list saved returns, but not to reopen one: a
return is a statement, not five numbers, and reopening it has to show the same lines, the
same account breakdown and the same daily table that were on it when it was saved. Those
cannot be recomputed later and come out the same - a backdated journal entry, a changed GL
mapping or a corrected rate all move figures that were already reported. So the rendered
return is stored alongside the summary, and a saved report is served from that snapshot
rather than recomputed.
Keyed by (reporting date, form code) rather than by fortnight alone: Form 1 and Form 9 are
separate returns that can both be saved for dates in one month, and a bank re-running Form 1
for a different date within the same fortnight is saving a different statement, not
overwriting the earlier one.
-->
<changeSet author="fineract" id="0582-1-form-a-filing-report-json">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_form_a_filing"/>
<not><columnExists tableName="m_rbi_form_a_filing" columnName="report_json"/></not>
</and>
</preConditions>
<addColumn tableName="m_rbi_form_a_filing">
<!-- The whole computed return as it was served when saved. TEXT rather than a typed
column set: the return's shape is the API's own, and mirroring it in columns would
mean a migration every time a line is added to the statement. -->
<column name="report_json" type="TEXT"/>
<!-- FORM_1 or FORM_9. Both are saved through the same screen and the same table. -->
<column name="form_code" type="VARCHAR(20)" defaultValue="FORM_1">
<constraints nullable="false"/>
</column>
<column name="office_id" type="BIGINT"/>
<!-- Denormalised from the snapshot so the list can show them without parsing the JSON. -->
<column name="period_start" type="DATE"/>
<column name="period_end" type="DATE"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0582-2-form-a-filing-rekey-by-date-and-form">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_form_a_filing"/>
<columnExists tableName="m_rbi_form_a_filing" columnName="form_code"/>
</and>
</preConditions>
<!--
0581 made the fortnight alone unique, which was right while a filing was one row per
fortnight. Saving a rendered return makes that wrong: Form 1 and Form 9 both save against
dates in the same fortnight and would collide, and re-saving Form 1 for a different
reporting date inside one fortnight is a second statement rather than a replacement.
Dropped and replaced rather than added alongside: leaving the old constraint in place
would keep rejecting exactly the rows this change exists to allow.
-->
<dropUniqueConstraint tableName="m_rbi_form_a_filing" constraintName="uk_rbi_form_a_filing_fortnight"/>
<addUniqueConstraint tableName="m_rbi_form_a_filing"
columnNames="form_code, reporting_date"
constraintName="uk_rbi_form_a_filing_form_date"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Saves each fortnight's Form A at the close of that fortnight.
What a Form A reports cannot be recovered afterwards: 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 one whose filed statement no longer exists anywhere. Leaving that to someone
remembering to press Save is leaving the bank's own record of what it filed to chance.
The cron fires at 23:30 on the 15th and on the last day of each month - the two days a
fortnight closes under RBI's reckoning. Late enough that the day's postings are in, and the
job re-checks the date itself rather than trusting the schedule, so a mis-set cron cannot
file a fortnight that has not closed.
It saves as DRAFT and stops there. Checking, approving and submitting remain human acts; the
job exists so the figures survive, not so a return files itself. It also never overwrites a
fortnight already saved, by hand or by an earlier run - re-running must not replace a
statement somebody has reviewed.
-->
<changeSet author="fineract" id="0583-1-save-rbi-form-a-return-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Save RBI Form A Return'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Save RBI Form A Return"/>
<column name="display_name" value="Save RBI Form A Return"/>
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
<column name="short_name" value="RBIFRMA"/>
<!-- 23:30 on the 15th and on the last day of the month. "L" is Quartz's last-day-of-month
token, which is what makes one expression cover both 28th-of-February and 31st-of-
January without a month-by-month list. -->
<column name="cron_expression" value="0 30 23 15,L * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Save RBI Form A ReturnJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The Manage Surveys screen (SPM scorecards, spm.api.SpmApiResource) previously performed
create/update/activate/deactivate directly against SpmService with only a login check -
no permission check at any layer. These 4 write operations have now been wired into the
standard command pipeline (CommandWrapperBuilder.createSurvey/updateSurvey/activateSurvey/
deactivateSurvey -> CreateSurveyCommandHandler/UpdateSurveyCommandHandler/
ActivateSurveyCommandHandler/DeactivateSurveyCommandHandler, entity "SURVEY"), which requires
these permission rows to exist so PortfolioCommandSourceWritePlatformServiceImpl.logCommandSource()
can authorize the caller. Seeded with can_maker_checker=false: no prior maker-checker behaviour
existed for this screen, and enabling maker-checker is a business decision left for later. -->
<changeSet id="0584_add_manage_survey_permissions" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_SURVEY'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="CREATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="UPDATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="ACTIVATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="ACTIVATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="DEACTIVATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="DEACTIVATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- No role previously held any permission for this screen (it had no permission check at all),
so there are no "roles that already hold the base permission" to copy from. Following the
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
Super user plus any manager-named role. -->
<changeSet id="0584_grant_manage_survey_permissions" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code IN ('CREATE_SURVEY', 'UPDATE_SURVEY', 'ACTIVATE_SURVEY', 'DEACTIVATE_SURVEY')
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Gives Form 9 the assets SLR is actually maintained in.
The statutory return is a statement of "cash, gold and unencumbered approved securities" -
three classes, not one. The return could previously only read a single built-in role for
government securities, so a bank holding gold had nowhere to state it and a bank holding
several categories of approved security had to collapse them into one figure.
Three changes:
- A new role usage, SLR_ASSET, separate from MAINTAINED. The two returns admit different
assets: CRR is met in cash and balances with banks, SLR in cash, gold and approved
securities. One usage for both would let a head configured for Form 1 inflate the SLR
position, which is the error the return exists to detect.
- A Gold head, which did not exist at all.
- The three approved-securities heads seeded by 0581 move from MEMO to SLR_ASSET. They
were memo only because nothing counted them; now something does. The advances and
money-at-call heads deliberately stay MEMO - they are reported on Form A, and neither is
an SLR asset.
What this does NOT do is decide eligibility. A security that is pledged or encumbered is not
SLR-eligible however it is classified, and a GL balance cannot express that - it needs a
security master holding each holding's eligibility and encumbrance. Until that exists, these
heads report what the ledger holds and an administrator maps only what is genuinely eligible.
Every changeSet is guarded so this migration is a safe no-op where already applied.
-->
<changeSet author="fineract" id="0584-1-widen-role-usage-constraint">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_rbi_reserve_role"/>
</preConditions>
<!--
0575 constrained role_usage to four values. SLR_ASSET is a fifth, so the check has to be
widened before any row can carry it - otherwise the inserts below are rejected by the
constraint rather than by anything meaningful.
Dropped and recreated rather than altered: a CHECK constraint has no in-place edit, and
recreating it keeps the guard rather than quietly leaving the column unconstrained.
-->
<sql>
ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage;
ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage
CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'MEMO'));
</sql>
</changeSet>
<changeSet author="fineract" id="0584-2-seed-gold-head">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key = 'FORMA_SLR_GOLD'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_SLR_GOLD"/>
<column name="label" value="Gold held in India"/>
<column name="description" value="Gold held in India, valued as the applicable rules require. Counts toward SLR maintained on Form 9. Map the GL that carries the gold holding - the return reads its balance, so that balance must already be at the valuation the return is to report."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="SLR_ASSET"/>
<column name="position" valueNumeric="210"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0584-3-securities-count-toward-slr">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="3">
SELECT COUNT(*) FROM m_rbi_reserve_role
WHERE config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT',
'FORMA_INV_OTHER_APPROVED')
</sqlCheck>
</and>
</preConditions>
<!--
Approved securities are what SLR is principally maintained in, so they count rather than
merely appear. Advances and money at call are left MEMO: both are reported on Form A and
neither is an SLR asset, and promoting them here would overstate the position.
-->
<update tableName="m_rbi_reserve_role">
<column name="role_usage" value="SLR_ASSET"/>
<where>
config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', 'FORMA_INV_OTHER_APPROVED')
AND role_usage = 'MEMO'
</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- LoanCOBCatchUpApiResource.executeLoanCOBCatchUp() (POST /v1/loans/catch-up) previously had no
permission check at all. A validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP") check was added
directly (not full CommandWrapper/maker-checker wiring, since this is a job-execution trigger,
not a CRUD operation on a persisted entity - matching the existing EXECUTE_INLINE_JOB precedent),
which requires this permission row to exist. can_maker_checker=false: this closes the
authorization gap only, it does not introduce maker-checker for job execution. -->
<changeSet id="0585_add_loan_cob_catchup_permission" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'EXECUTE_LOANCOBCATCHUP'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="EXECUTE_LOANCOBCATCHUP"/>
<column name="entity_name" value="LOANCOBCATCHUP"/>
<column name="action_name" value="EXECUTE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- No role previously held any permission for this endpoint (it had no permission check at all),
so there are no "roles that already hold the base permission" to copy from. Following the
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
Super user plus any manager-named role. -->
<changeSet id="0585_grant_loan_cob_catchup_permission" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code = 'EXECUTE_LOANCOBCATCHUP'
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The Admin -> System maker-checker audit found 8 base permissions whose "_CHECKER" counterpart
row was never seeded (AppUser.validateHasCheckerPermissionTo() computes
checkerPermissionName = code.toUpperCase() + "_CHECKER" and requires the approver to hold
either CHECKER_SUPER_USER or that exact permission code - see the identical issue already
fixed for LOAN_ORIGINATOR in 0583_add_loan_originator_checker_permissions.xml). Without these
rows, no ordinary role can ever be granted checker rights for these operations, and
UPDATE_BATCH_BUSINESS_STEP (Manage Jobs - COB Workflow Step Reorder) is the one permission in
all of Admin -> System that already has can_maker_checker=true today, making its missing
checker row a live gap, not merely a structural one. All 8 rows are seeded with
can_maker_checker=false: this changeset only makes the approval path structurally ready, it
does not itself enable maker-checker on any base permission.
For CREATE/UPDATE/DELETE_ACCOUNTNUMBERFORMAT_CHECKER, entity_name is set to
"ACCOUNTNUMBERFORMAT" for all three (matching CREATE_ACCOUNTNUMBERFORMAT and
UPDATE_ACCOUNTNUMBERFORMAT). The pre-existing base permission DELETE_ACCOUNTNUMBERFORMAT has
entity_name="HOOK" (a pre-existing data bug, confirmed via read-only query, left untouched -
fixing an existing permission row's value was out of scope for this change). This checker row
deliberately uses the correct entity name rather than propagating that bug. -->
<changeSet id="0586_add_system_module_checker_permissions" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_ACCOUNTNUMBERFORMAT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="CREATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="UPDATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="DELETE_ACCOUNTNUMBERFORMAT_CHECKER"/>
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="CREATE_ENTITYMAPPING_CHECKER"/>
<column name="entity_name" value="ENTITYMAPPING"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="UPDATE_ENTITYMAPPING_CHECKER"/>
<column name="entity_name" value="ENTITYMAPPING"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="DELETE_ENTITYMAPPING_CHECKER"/>
<column name="entity_name" value="ENTITYMAPPING"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="CREATE_USERAGREEMENT_CHECKER"/>
<column name="entity_name" value="USERAGREEMENT"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_BATCH_BUSINESS_STEP_CHECKER"/>
<column name="entity_name" value="BATCH_BUSINESS_STEP"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Granted to whichever roles already hold the corresponding base permission - the same
"grant to roles that already hold the base permission" convention used in
0583_add_loan_originator_checker_permissions.xml. -->
<changeSet id="0586_grant_system_module_checker_permissions" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT rp.role_id, checker.id
FROM m_role_permission rp
JOIN m_permission base ON base.id = rp.permission_id
JOIN m_permission checker ON checker.code = base.code || '_CHECKER'
WHERE base.code IN ('CREATE_ACCOUNTNUMBERFORMAT', 'UPDATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
'CREATE_ENTITYMAPPING', 'UPDATE_ENTITYMAPPING', 'DELETE_ENTITYMAPPING',
'CREATE_USERAGREEMENT', 'UPDATE_BATCH_BUSINESS_STEP')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<!--
2033 (savings module changelog) was meant to add the OD-scheme columns from 0577
(m_current_product) onto the pending-edit shadow table too, so an approved Current product edit
doesn't silently drop them. It guards on tableExists("m_savings_product_temp"), with an "s",
but the table 0369 actually created is "m_saving_product_temp" (singular, no "s"). That guard has
therefore never once matched, on any tenant: onFail="MARK_RAN" records 2033 as applied without
ever running its addColumn, so the columns were never added anywhere, and any edit to a Current
product with OD fields set fails the moment it reaches the pending-edit table ("column
od_excess_allowed does not exist"). Left 2033 itself untouched, since rewriting an already-applied
changeset's history is the wrong fix, and repeats it here against the table name that's actually
real, guarded the same way so it's a no-op wherever a hand patch already added these.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="CBS" id="0587-1-fix-saving-product-temp-od-min-limit">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_saving_product_temp"/>
<not>
<columnExists tableName="m_saving_product_temp" columnName="od_min_limit"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="od_min_limit" type="DECIMAL(19,6)"/>
<column name="od_security_type" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
<changeSet author="CBS" id="0587-2-fix-saving-product-temp-od-excess-drawing">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_saving_product_temp"/>
<not>
<columnExists tableName="m_saving_product_temp" columnName="od_excess_allowed"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="od_excess_allowed" type="BOOLEAN"/>
<column name="od_excess_limit_percent" type="DECIMAL(19,6)"/>
<column name="od_penal_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
dev database - none of that was ever captured by a migration, so a fresh/other environment
restored from this changelog history would come up with every one of them still at its
originally-seeded false value. Every code here was independently confirmed, via read-only
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
= false and currently true in the running dev DB with no existing changeset already covering
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
This changeset only ever moves can_maker_checker from false to true for these exact codes -
entity_name, action_name, grouping and every other column are left exactly as their origin
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
touched: those already exist for every code below (added earlier by 0583/0586, or by base
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
Grouped by the screen each permission belongs to:
- Currency Configuration: UPDATE_CURRENCY
- Manage Funds: CREATE_FUND
- Working Days: UPDATE_WORKINGDAYS
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
- Branch Profile: UPDATE_BRANCHPROFILE
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
- Manage User Agreement: CREATE_USERAGREEMENT
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
Survey permissions (confirmed still false - never actually toggled on)
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
Organization or Admin -> System - out of scope for this change and left as pre-existing
drift for a separate decision. -->
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
<update tableName="m_permission">
<column name="can_maker_checker" valueBoolean="true"/>
<where>code IN (
'UPDATE_CURRENCY',
'CREATE_FUND',
'UPDATE_WORKINGDAYS',
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
'UPDATE_BRANCHPROFILE',
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
'CREATE_USERAGREEMENT',
'UPDATE_BATCH_BUSINESS_STEP'
)</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -116,7 +116,11 @@ public class CommandSourceServiceTest {
} }
@Test @Test
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() { public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
// replaces the old bypass this test used to assert.
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class); CommandSource commandSource = Mockito.mock(CommandSource.class);
@@ -125,6 +129,8 @@ public class CommandSourceServiceTest {
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT"); Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT"); Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW"); Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
Mockito.when(commandSource.getId()).thenReturn(300L);
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true); Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
@@ -132,12 +138,12 @@ public class CommandSourceServiceTest {
.withCommandId(1L).build(); .withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
commandSource, superUser, false); underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Assertions.assertNotNull(result); Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource).markAsChecked(superUser); Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
} }
@Test @Test
@@ -105,14 +105,28 @@ public interface DepositAccountWritePlatformService {
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
/** /**
* Credits gap-period interest (simple interest, at the linked Savings/Current account's product * Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto
* rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning * that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has
* zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the * passed; a no-op, returning zero, if still within it. Paid to the linked account because it was
* generic `payment` command (the same one the account's own Account Actions menu uses), so every * earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it
* closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit} * renews from its real maturity amount regardless of which closure type follows. Called as a
* itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). * pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same
* one the account's own Account Actions menu uses), so every closure type is available from the
* Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns
* {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
*/ */
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId); Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
/**
* Read-only preview of what renewing this matured FD right now would do -- shown in the FD
* Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns
* isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace,
* currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo,
* linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and
* projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually
* renews).
*/
Map<String, Object> previewFdRenewal(Long fdAccountId);
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
} }