Compare commits

...
Author SHA1 Message Date
nitesh fd827dec27 fix(accounting,branchday): 3-stage FD interest posting in scheduler, fix branch day close business date validation
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-10-04 16:02:25 +05:30
nitesh aa264097e7 fix(savings,accounting): fix premature interest calculation date bounds and load interest payable GL mapping on edit
Details of changes:

1. Premature Closure 1-Day Shortfall Fix (FixedDepositAccount & RecurringDepositAccount):
- In calculatePreMatureInterest, explicitly reset transaction end-of-day balances to preMatureDate before invoking calculateInterestPayable.
- Ensure end-of-day balances are restored to current business local date in a finally block.
- Resolves the issue where premature closure missed 1 day of interest (e.g., -17.80 clawback) due to transaction end-of-day balances defaulting to the previous day when premature date matched current business date.

2. Interest Transfer Exclusions (SavingsAccountInterestPostingServiceImpl):
- Exclude interest-transfer withdrawal transactions from non-interest posting transactions and end-of-day cumulative balance updates for fixed deposits transferring interest to linked accounts.

3. Interest Payable GL Load on Edit (AccountingConstants & ProductToGLAccountMappingReadPlatformServiceImpl):
- Added INTEREST_PAYABLE(17) enum constant to CashAccountsForSavings in AccountingConstants.
- Mapped CashAccountsForSavings.INTEREST_PAYABLE to SavingProductAccountingDataParams.INTEREST_PAYABLE in ProductToGLAccountMappingReadPlatformServiceImpl.
- Fixes the bug where Liability -> Interest Payable Account GL was not loading in the product edit form (e.g., /products/fixed-deposit-products/:id/edit) even after being saved.
2026-10-04 11:54:28 +05:30
nitesh 16c67f6701 feat: Fixed Deposit - premature close logic, deposit account closure type, transaction validation, domain service and read service updates 2026-10-04 09:12:17 +05:30
nitesh e846892e9f Merge branch 'feature/bugfix' into dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract 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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Brings in the backend counterpart of "Enforce same-user maker-checker
restriction".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-10-01 13:25:29 +05:30
keshav 56d98bac8d feat(rbi-reserve,savings): add Form1/Form9 returns, interest posting report and branch-day enhancements
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 - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
2026-10-01 13:07:58 +05:30
niteshandClaude Sonnet 5 0d6066e0b6 fix(useradmin): strip password placeholder from a pending-edit replay payload
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 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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
A pending user edit's stored payload keeps a "[PROTECTED]" placeholder for
the password rather than the real value. The actual new password is applied
separately via request.getPasswordHash(), so the placeholder must not reach
AppUser.update() or it tries to re-encode the literal placeholder string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:13:02 +05:30
niteshandClaude Sonnet 5 4499b8afad fix(accounting): post GL entries for a negative (clawback) interest transaction
A premature FD closure that reprices interest downward (e.g. a pre-closure
penalty) posts the difference as a negative interest-posting transaction --
a clawback of interest already recognised in earlier periods. Both the cash-
and accrual-based accounting processors only posted journal entries when the
transaction amount was greater than zero, so a clawback updated the FD
subledger but silently posted nothing to the General Ledger, leaving the two
out of sync.

Treats a negative amount as a mirror-image reversal: positive magnitude,
debit/credit legs swapped -- the same mechanism already used for isReversal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:12:51 +05:30
niteshandClaude Sonnet 5 11a206f42a feat(deposits): RBI premature closure compliance, simplified to always use the contracted rate
Adds a Closure Reason to premature FD closure (Normal, Premature Extension,
Conversion to Another TD, Deceased Settlement, Nominee/Legal-Heir), persisted
as an RBI audit trail on m_deposit_account_term_and_preclosure. RBI-mandated
reasons auto-waive the pre-closure penalty regardless of the maker's own
checkbox; the maker can also waive it explicitly for any other reason.

Interest rate for a premature closure is always the deposit's own contracted
rate -- the same one its regular scheduled postings use throughout its life
-- rather than a slab re-lookup against the actual period held. An earlier
"actual-period vs contracted" toggle was built, tested, then deliberately
removed per explicit request: simpler, and avoids disagreeing with interest
the account was already accruing all along.

Also fixes a real bug found while testing this: a renewed FD term's premature-
closure interest was including the previous term's closing transaction a
second time (it's dated the same day the new term starts), understating the
first day's balance by the whole prior term's payout. Scoped the interest
calculation to transactions strictly after the current term's start date.

Known open issue: a renewed/auto-renewed account can still show a materially
wrong (sometimes negative) premature-closure interest figure in some cases
(seen on account 10, a QUARTERLY-posting auto-renewed FD) -- root cause not
yet confirmed; needs further investigation with debug instrumentation before
this is considered fully correct for every renewal shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:12:40 +05:30
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 ce8bb7e5ca fix(products): named GL account changes (e.g. Savings Interest Payable/Paid) were silently lost on a pending edit
Root cause: ProductPendingEditFieldMapper.populateCoreFields() captured
otherGlMappings with stringValueOfParameterNamed(), which only handles a
JSON string primitive. The maker's edit actually submits otherGlMappings
as a nested {assets,liabilities,expenses,income} object -- the same shape
SavingsProduct#update() already reads correctly via jsonFragment() -- so
the pending-edit row was staged with this field blank every time,
regardless of what the maker changed.

Two consequences, both now fixed:
 - The checker's diff dialog showed "No changes to review" even when a GL
   account mapping (e.g. Savings/Current/FD/RD Interest Payable or Paid)
   was genuinely changed, since the submitted value never made it into
   proposedChanges.
 - Approving the edit replayed a payload with no otherGlMappings at all,
   so the GL account change was silently dropped rather than applied --
   the edit showed as approved, but the product's accounting mapping
   never actually moved.

Fixed the write side (jsonFragment instead of stringValueOfParameterNamed)
and the read-side replay (writeCoreFieldsToJson now re-adds the stored
value as a parsed JSON element via the new putJsonFragment, not a quoted
string, which would have double-encoded it on replay). Shared by Savings,
Current, FD and RD products, since they all go through this one mapper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:39:58 +05:30
niteshandClaude Sonnet 5 82336cbe98 fix: remove illegal -- from migration 0012's XML comment
Same mistake as an earlier migration this session: a literal "--" inside
an XML comment is illegal per the XML spec and crashes the entire backend
startup (Liquibase's XML parser rejects the whole changelog, not just this
file). Reworded the prose to avoid it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:00:59 +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
103 changed files with 7965 additions and 851 deletions
@@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
// Income
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
// Interest Payable is editable on cash too, and optional. Interest posting routes
// through it on either basis, so a product can name one to carry interest declared
// but not yet credited; a product that names none still posts straight to Savings
// Control. Created-or-merged because a product configured before this row existed
// has no mapping to merge into.
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
break;
case ACCRUAL_PERIODIC:
// asset
@@ -270,6 +270,7 @@ public final class AccountingConstants {
INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), //
ESCHEAT_LIABILITY(14), //
INTEREST_PAYABLE(17), //
/**
* Where the paise dropped from a cash closure are absorbed.
*
@@ -137,7 +137,16 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
// isApprovedByChecker is only ever true here for a genuine checker approval
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
// auto-approve their own submission just by holding it.
if (isApprovedByChecker) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,25 +160,6 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -4809,4 +4809,36 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/originators/" + originatorId;
return this;
}
public CommandWrapperBuilder createSurvey() {
this.actionName = "CREATE";
this.entityName = "SURVEY";
this.entityId = null;
this.href = "/surveys";
return this;
}
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
this.actionName = "UPDATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
this.actionName = "ACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
this.actionName = "DEACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
}
@@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
private final FromJsonHelper fromApiJsonHelper;
private final CommandProcessingService processAndLogCommandService;
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
// live, independently-configurable global switch (System -> Configurations); deleting this field,
// and by extension deciding that switch should stop existing altogether, is a separate decision
// from closing this specific self-approval loophole.
private final ConfigurationDomainService configurationService;
@Override
@@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode);
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
}
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
}
// Maker-identity rule: the user who submitted a command may never be the one who approves
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
// permission still lets its holder action a DIFFERENT user's request without needing the
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
// longer lets them action their own) and not overridable by the enable-same-maker-checker
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
// configuration is left in place and unused for this decision rather than removed, since
// deleting a still-configurable global setting is a separate decision from this one).
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
}
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
}
return commandSource;
}
@@ -43,6 +43,7 @@ public enum JobName {
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -73,6 +74,7 @@ public enum JobName {
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
; //
private final String name;
@@ -55,17 +55,25 @@ public enum PortfolioProductType {
return null;
}
// Matched against each constant's own declared value above, not its declaration position --
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
// (entity_type_enum=6) reached this on the Journal Entries screen.
switch (v) {
case 1:
return LOAN;
case 2:
return SAVING;
case 3:
return CLIENT;
case 4:
return PROVISIONING;
case 5:
case 4:
return SHARES;
case 5:
return CLIENT;
case 6:
return CURRENT;
default:
return null;
}
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
private final Integer value;
private final String code;
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
case 600 -> TRANSFER_TO_GL;
default -> INVALID;
};
}
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
return this.equals(PARTIAL_REINVEST);
}
/**
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
* Only the fixed-deposit premature-closure handler implements it -- see
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
* terminal "pay the balance out in cash" branch.
*/
public boolean isTransferToGl() {
return this.equals(TRANSFER_TO_GL);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -212,7 +212,18 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
// credited straight to this GL instead of a savings account or cash.
public static final String toGlAccountIdParamName = "toGlAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
// per-request flag rather than a stored account field.
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
// penalty and why.
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -241,7 +252,7 @@ public final class DepositsApiConstants {
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
SavingsApiConstants.taxGroupIdParamName));
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
@@ -379,6 +390,8 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
fixedDepositRequestData.add("feeToIncomeAccountMappings");
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
fixedDepositRequestData.add("otherGlMappingsMap");
return fixedDepositRequestData;
}
@@ -490,6 +503,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
recurringDepositRequestData.add("feeToIncomeAccountMappings");
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
recurringDepositRequestData.add("otherGlMappingsMap");
return recurringDepositRequestData;
}
@@ -0,0 +1,70 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings;
import lombok.Getter;
/**
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
* server regardless of what a client happened to also send for the plain waive-penalty flag.
*/
@Getter
public enum PrematureClosureReasonType {
INVALID(0, "prematureClosureReasonType.invalid", false), //
NORMAL(100, "prematureClosureReasonType.normal", false), //
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
private final Integer value;
private final String code;
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
* waive-penalty flag also sent -- see the class javadoc. */
private final boolean autoWaived;
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
this.value = value;
this.code = code;
this.autoWaived = autoWaived;
}
public static PrematureClosureReasonType fromInt(final Integer v) {
if (v == null) {
return INVALID;
}
return switch (v) {
case 100 -> NORMAL;
case 200 -> PREMATURE_EXTENSION;
case 300 -> CONVERSION_TO_ANOTHER_TD;
case 400 -> DECEASED_SETTLEMENT;
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
default -> INVALID;
};
}
public boolean isInvalid() {
return this.equals(INVALID);
}
}
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
case TRANSFER_TO_GL:
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
break;
}
return optionData;
}
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
// active) are the only statuses a second application on the same product should actually collide
// with.
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
}
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
"glAccountId", glAccountId);
}
}
/**
* Refuses to draw more out of a holding account than it holds.
*
* <p>
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
* liability with a negative balance is not a small number - it says the bank owes less than
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
* and the figure is small enough to sit unnoticed inside a head that totals positive.
*
* <p>
* Checked here rather than at each call site because the accounts this protects are reached by
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
* the next one added will not have.
*
* @param glAccountId the account being drawn on.
* @param amount how much the caller wants to take out.
* @param what the account's role in the caller's own words, for the message a clerk reads.
*/
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
final java.math.BigDecimal amount, final String what) {
if (glAccountId == null || amount == null || amount.signum() <= 0) {
return;
}
// Credits less debits: these are liability-natured holding accounts, so the balance is what
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
// reversal pair nets to nothing, and counting either side alone moves the balance by the
// whole amount.
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
FROM acc_gl_journal_entry
WHERE account_id = ?
AND (reversed IS NULL OR reversed = false)
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""", java.math.BigDecimal.class, glAccountId);
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
if (balance.compareTo(amount) < 0) {
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
+ "difference would leave the account with a negative balance, which a "
+ "holding account cannot have.",
"glAccountId", glAccountId, amount);
}
}
}
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
@@ -42,6 +43,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
@@ -82,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@Slf4j
@RequiredArgsConstructor
public class AccountingProcessorHelper {
@@ -651,6 +654,8 @@ public class AccountingProcessorHelper {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
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
// where a bank actually picks it. The reserved name in the product's other GL mappings is
@@ -659,30 +664,78 @@ public class AccountingProcessorHelper {
final GLAccount payableAccount = Optional
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
.orElse(controlAccount);
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
.orElse(payableAccount);
// The interest expense is not raised here. It is recognised as the interest accrues, day by
// day, and this posting settles that recognised debt - raising the expense again at payout
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
// which pays a product's whole membership the same way; a closure reaching this path must
// produce the same entries as the nightly run, or the two disagree on what one payment did.
//
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
// product names no paid account both resolve to the payable and this says nothing, so it is
// skipped and the payout comes straight out of the payable.
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
if (!payableIsPaid) {
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(),
controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(),
amount, isReversal);
// A product that names no payable falls back to Savings Control above. The chain below would
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
// reverse, the two cancelling: the expense never charged and the member never credited. Such
// a product has no liability to draw down, so it keeps the single pair it always posted.
if (payableAccount.getId().equals(controlAccount.getId())) {
log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry",
payableAccount.getId());
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
return;
}
// Stage 2: into the member's own balance.
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// The interest is charged to expense and raised as a liability first, for whatever part of
// it no accrual has already covered.
//
// The chain below draws the payable DOWN, so it assumes an accrual already credited it for
// the period. Where none ran - a product posting on cash accounting, or a period the accrual
// job never covered - the payable went negative by the interest and the expense netted to
// nothing across the next two legs, leaving the charge off the P&L entirely. A liability
// account cannot hold a negative balance: the bank does not owe less than nothing.
//
// So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
// payable already carries the interest and this leg writes nothing - the balance below is
// enough, and raising it again would double the liability.
//
// Only a liability payable is topped up. A bank that maps an expense GL as the payable books
// the cost there directly: payableBalance reads credit minus debit, so an expense's normal
// debit balance would come back negative and every posting would raise a shortfall larger
// than the interest itself.
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
alreadyAccrued, shortfall, payableIsLiability, isReversal);
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]",
interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall);
// Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
shortfall, false);
}
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount);
// Stage 2: Out of the payable, into the interest-paid transit/clearing account
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// Stage 3: Out of the interest-paid account, into the member's control account
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
} else {
log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]",
payableAccount.getName(), controlAccount.getName(), amount);
// Payout directly from payable into member's control account
postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
}
/**
@@ -724,34 +777,69 @@ public class AccountingProcessorHelper {
*
* @return the account, or null where the product does not configure this name
*/
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
if (savingsProductId == null) {
/**
* What the interest payable account currently holds for this office, on its own liability side.
*
* <p>
* Read so an interest posting can tell an accrual that already ran from one that did not. The
* posting chain draws the payable down; where nothing had raised it, the account went negative
* and the expense netted to nothing, so the charge never reached the P&amp;L at all. The caller
* raises only the shortfall, which is nil when the accrual did run.
*
* <p>
* Credits less debits, because a payable is a liability: the balance is what the bank still
* owes. Floored at zero by the caller rather than here - a negative balance is a real state to
* report, and the caller's arithmetic needs the true figure, not a clamped one.
*/
private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
if (payableAccount == null || payableAccount.getId() == null) {
return BigDecimal.ZERO;
}
// Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
// nothing, and counting either side alone would move the balance by the whole amount.
final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
FROM acc_gl_journal_entry
WHERE account_id = ?
AND office_id = ?
AND currency_code = ?
AND (reversed IS NULL OR reversed = false)
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""", BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
return balance == null ? BigDecimal.ZERO : balance;
}
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
return null;
}
// The column holds an empty string on a product saved with no named mappings, and casting
// that to jsonb is an error rather than an empty document - so the cast is done inside a
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
// clause in any order, so guarding at the same query level as the cast would only work by
// luck.
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE m.entry ->> 'accountName' = ?
LIMIT 1
""", Long.class, savingsProductId, accountName);
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
for (final String accountName : accountNames) {
if (accountName == null) {
continue;
}
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
LIMIT 1
""", Long.class, savingsProductId, accountName);
if (!found.isEmpty()) {
return glAccountRepository.findById(found.get(0)).orElse(null);
}
}
return null;
}
/**
@@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
}
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
// A premature FD closure that reprices interest downward (see
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
// interest-posting transaction -- a clawback of interest already recognised in earlier
// periods, not "zero interest, nothing to post". Skipping it here (the original
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
// cash-based path.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
// Routed through the same helper the cash processor uses, so a posting states
// the whole chain the product is configured for -- out of the payable, through
// the interest-paid account, into the member's balance -- rather than the single
// payable-to-control pair that used to be here, which skipped interest-paid
// entirely and left the expense off the P&L.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
}
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
// A premature FD closure that reprices interest downward (see
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
// interest-posting transaction -- a clawback of interest already recognised in earlier
// periods, not "zero interest, nothing to post". Skipping it here (the original
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
// in sync instead.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
// Routed through the payable and paid accounts the product names, so the society
// can read interest declared but not yet credited as its own balance. Collapses
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, amount, isReversal);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -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(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ")
// entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and
// current-account entries -- current products deliberately share SAVING's GL type
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two
// 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
// 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 here -- only a real savings transaction sets it -- so
// it decides first; only when it's absent (a current-account entry, or a savings
// entry with no linked transaction row) does the current-account guess apply.
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
// m_current_account -- newer write paths tag current-account entries with it
// instead of reusing SAVING's type, specifically so this case needs no guessing.
//
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
// and current-account entries by older write paths not yet updated to the above --
// current products used to deliberately share SAVING's GL type space (see
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
// m_current_account each have their own id sequence, so an entity_id can be a real
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
// account's number even for an entry that is genuinely a savings one.
// savings_transaction_id is the one unambiguous signal available for those older
// entries -- only a real savings transaction sets it -- so it decides first; only
// when it's absent does the current-account guess apply.
.append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" ELSE NULL ")
@@ -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 eao on eao.id = eajem.owner_id ")
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
break;
case CASH_BASED:
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
// Interest Payable is saved on cash too, and optionally: interest posting routes
// through it on either basis (see AccountingProcessorHelper
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
// yet credited to the member reads as the bank's own liability rather than appearing
// only at the moment it is paid. Mapped only when the screen supplies it - a product
// naming none still posts straight to Savings Control, as it always did.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
element)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
}
break;
case ACCRUAL_PERIODIC:
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component;
@Path("/v1/loans")
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
public class LoanCOBCatchUpApiResource {
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
private final PlatformSecurityContext context;
@GET
@Path("oldest-cob-closed")
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
public Response executeLoanCOBCatchUp() {
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
return Response.status(Response.Status.BAD_REQUEST).build();
@@ -165,9 +165,11 @@ public class SecurityConfig {
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
// since any bank's own super user reaching it could mint a platform-wide key). Same
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
*
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
* whoever already has direct database access -- never over HTTP.
*/
@Path("/v1/apiclients")
@Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
requireSuperUser();
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
}
@GET
@@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
// m_savings_account and m_current_account each keep their own identity sequence, so the same
// entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to
// be written for both savings and current-account entries alike, so esa/eca could both match the
// same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever
// client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of
// misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT)
// is what newer write paths tag current-account entries with instead, unambiguously; for older
// entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine
// savings transaction sets it), checked before the ambiguous current/savings guess.
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END,
CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6)
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -73,6 +75,8 @@ public class BranchDayRegisterApiResource {
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VaultPositionService vaultPositionService;
private final SavingsInterestPostingReportService interestPostingReportService;
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -200,7 +204,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
@@ -211,7 +215,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
@@ -589,6 +593,34 @@ public class BranchDayRegisterApiResource {
"Business day " + businessDate + " is already closed.");
}
// ── This branch's savings interest reports must be approved before it closes ──
// Not overridable by a force-close reason: the branch posts its own customers' interest when
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!interestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
"Cannot close the day — this branch's savings interest posting report is not approved for: "
+ String.join(", ", interestPending)
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
}
// ── The head office closes last ──
// Every branch that opened the day closes first and posts its own interest; a branch that
// never opened is not waited for.
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
if (isHeadOffice) {
final List<String> stillOpen = jdbcTemplate.queryForList("""
SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ?
ORDER BY o.name
""", String.class, Date.valueOf(businessDate), officeId);
if (!stillOpen.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
+ ". The head office closes last.");
}
}
// ── Ensure every cashier holding cash has been reconciled AND settled ──
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
// and if so, verify reconciliation and settlement records exist for the business date.
@@ -687,6 +719,28 @@ public class BranchDayRegisterApiResource {
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
// ── The branch posts its own approved savings interest as it closes ──
// The head office also posts what it approved for branches that did not open today. Entries
// are raised in the customers' own branch either way.
//
// Posted here, before the close is written, and synchronously, so the outcome is never
// silent: each report posts in a transaction of its own and commits, and if any fails the
// close is refused with the reason — the day stays open, the report stays APPROVED, and
// closing again once the cause is fixed posts it.
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!toPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
if (!interestFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
// this write are two separate statements, so two managers pressing Close at once (or one
// double-click) both got past the check. The second UPDATE then overwrote the first's
@@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource {
updatedLog, Timestamp.from(Instant.now()), id
);
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
// means the cashier took the cash again, so the notes are recorded again -- without this an
// approved resubmission credited the account while the drawer never received its notes.
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
(String) record.get("account_no"), toLong(record.get("client_id")),
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
// Keep the central voucher register in sync with the edited amount
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
@@ -1004,9 +1015,14 @@ public class CashierDepositRequestApiResource {
*/
// Called at submit time — uses request id as temporary txn_id.
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo,
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
final String txnDate) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -1037,10 +1053,14 @@ public class CashierDepositRequestApiResource {
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
""",
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// Dated on the request's own date, which is what the branch day's book figure reads.
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
// drawer on another and leave both days' cash wrong.
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
cashierId, cashierUsername, paymentTypeName
);
}
@@ -90,7 +90,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -120,7 +120,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -60,34 +60,11 @@ public class CashReportApiResource {
* </ul>
*
* <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* reads the same on all four.
* The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
* shared with the day register, the reconciliation screen and the withdrawal stock check, so a
* drawer reads the same on all four.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
// such transactions in this database were carrying ₹1.68 lakh that never existed.
//
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
// everything counted after it) out of the cashier's position.
//
// Compared per transaction inside a correlated subquery rather than as a join, so a
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
/**
* Day book — every voucher raised on one date, in one list, newest first.
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
@@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService {
}
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId);
accountId, accountType);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId);
@@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService {
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) {
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
final ChequeAccountType accountType) {
final JsonArray debitArr = new JsonArray();
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", debitGlId);
@@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService {
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
final String glTxnId = result.getTransactionId();
if (glTxnId != null) {
linkDebitEntryToAccount(glTxnId, accountId);
linkDebitEntryToAccount(glTxnId, accountId, accountType);
}
}
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
/**
* Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an
* Account No.
*
* m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can
* be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of
* accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings
* account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually
* means "this id is in m_current_account" -- use it whenever accountType says so.
*/
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
@@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService {
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
final int entityType = accountType == ChequeAccountType.CURRENT
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
accountId, rowIds.get(0));
}
}
}
@@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -118,7 +119,7 @@ public class ClearingJournalService {
}
final String glTxnId = result.getTransactionId();
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType);
}
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
return glTxnId;
@@ -191,7 +192,13 @@ public class ClearingJournalService {
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
/**
* Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two
* account tables number independently, so entity_type_enum must say which one accountId names, not
* assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means
* "this id is in m_current_account".
*/
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
@@ -200,8 +207,11 @@ public class ClearingJournalService {
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
final int entityType = "CURRENT".equalsIgnoreCase(accountType)
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
accountId, rowIds.get(0));
}
}
}
@@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
@@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource {
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
@@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
}
@@ -630,7 +631,15 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
// submit() already raised this request's voucher (every request type does, so it
// shows in My Vouchers the moment a clerk submits it, before any checker acts) --
// minting a second one here for Bank Transfer specifically double-counted the same
// transfer under two different names, "Entered by" whoever raised it AND whoever
// approved it. Reuse that voucher; only fall back to raising a fresh one if this
// request somehow reached approval without it (defensive, not the normal path).
final Object existingVoucherNo = record.get("voucher_no");
final String btVoucherNo = existingVoucherNo != null ? (String) existingVoucherNo
: generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -681,7 +690,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(directCaTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true));
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
} else {
// Real CBS book transfer: direct credit to savings account.
@@ -696,7 +705,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(directTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false));
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
}
} else {
@@ -715,7 +724,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(debitTxnId));
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount));
if (isCurrentAccount) {
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
} else {
@@ -979,7 +988,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else {
@@ -1005,7 +1014,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else {
@@ -1568,20 +1577,27 @@ public class ClerkDisbursementRequestApiResource {
final BigDecimal amount;
final String accountRef; // savings account number shown in GL comments; null for source/target GL
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) {
this(glAccountId, amount, accountRef, txnId, null);
this(glAccountId, amount, accountRef, txnId, null, false);
}
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId, final Long savingsAccountId) {
this(glAccountId, amount, accountRef, txnId, savingsAccountId, false);
}
GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId,
final Long savingsAccountId, final boolean isCurrentAccount) {
this.glAccountId = glAccountId;
this.amount = amount;
this.accountRef = accountRef;
this.txnId = txnId;
this.savingsAccountId = savingsAccountId;
this.isCurrentAccount = isCurrentAccount;
}
}
@@ -1603,8 +1619,16 @@ public class ClerkDisbursementRequestApiResource {
/**
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
* row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—".
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
*
* m_current_account and m_savings_account each keep their own identity sequence, so the same
* numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2
* (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view
* would resolve a current account's entry to a same-numbered savings account or vice versa.
* entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's
* entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one
* this entry's id actually is, decided once already, where the account was first resolved.
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) {
@@ -1618,11 +1642,15 @@ public class ClerkDisbursementRequestApiResource {
Long.class, glTxnId, typeEnum, entries.size());
java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
final GlEntry entry = entries.get(i);
final Long savingsAccId = entry.savingsAccountId;
if (savingsAccId != null) {
final int entityType = entry.isCurrentAccount
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
savingsAccId, rowIds.get(i));
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?",
entityType, savingsAccId, rowIds.get(i));
}
}
}
@@ -709,6 +709,21 @@ public class ClerkShareWithdrawalRequestApiResource {
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
}
// The Share Sundry account the redemption draws on must actually hold the money.
//
// collectionApplied above says A collection was applied to THIS account, not that the
// proceeds are still sitting in Sundry - the same holding account serves every share
// account, and an earlier redemption may already have drawn it down. Approving past that
// takes Sundry negative, which is a liability owing less than nothing, and the overdraw
// lands inside a balance-sheet head that totals positive, where it does not look wrong.
final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares);
if (redemptionValue != null) {
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
shareAccountId);
GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue,
"The Share Sundry (Suspense) account");
}
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
@@ -958,6 +973,33 @@ public class ClerkShareWithdrawalRequestApiResource {
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
* reset id sequence in a dev environment can violate.
*/
/**
* What a redemption of this many shares will draw out of Share Sundry.
*
* <p>
* Priced at the unit price the member's own shares were issued at, which is the price the
* redemption itself uses. The product's nominal price is not consulted: a member who subscribed
* at an earlier price redeems at theirs, and pricing the guard differently from the posting
* would either block a redemption that fits or let one through that does not.
*
* @return the value, or null where no price can be established - the guard then stands aside
* rather than blocking on a figure it had to invent.
*/
private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) {
if (shareAccountId == null || requestedShares == null || requestedShares <= 0) {
return null;
}
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
final BigDecimal unitPrice = jdbcTemplate
.queryForList("""
SELECT unit_price FROM m_share_account_transaction
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
ORDER BY id DESC LIMIT 1
""", BigDecimal.class, shareAccountId)
.stream().findFirst().orElse(null);
return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares));
}
private Long redeemDebitGlOf(final Long shareTransactionId) {
if (shareTransactionId == null) {
return null;
@@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource {
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'WITHDRAWAL', ?, ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// The request's own date, which the branch day's paid-out figure reads -- not the
// database clock, which put the notes on a different day from the withdrawal.
record.get("txn_date"),
cashier.getId(), cashier.getUsername(), paymentTypeName
);
}
@@ -87,4 +87,53 @@ public final class CountedCashLedger {
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
/**
* {@link #REAL_TXN_ID} plus the two tests a real id can still fail, as one predicate over the alias
* {@code std}. Every drawer figure reads this, not {@code REAL_TXN_ID} alone.
*
* <p>
* Only the cashier position report used to apply the two extra tests, so it and the reconciliation
* screen disagreed: a reversed ₹200 deposit left the settlement screen expecting three ₹100 notes
* while the settlement check, reading the position, allowed one — and refused the hand-over.
*
* <ul>
* <li>a breakdown whose savings transaction is reversed -- the money went back, so the notes did too.
* The table has no reversal marker of its own, so it is read off the transaction;
* <li>a breakdown that does not add up to its transaction -- ₹1,60,000 of notes against a ₹918
* deposit is mis-keyed, not cash. Sub-rupee tolerance, because a paisa-exact amount is handed
* over as the nearest whole rupee.
* </ul>
*
* <p>
* <b>The transaction is found by id and account number together.</b> Savings and current
* transactions keep separate id sequences, and a current-account deposit writes its own
* transaction id into {@code txn_id}. Matched on id alone, a ₹210 current-account deposit was
* judged against an unrelated savings transaction that happened to share its id, failed the
* amount test, and vanished from the cashier's drawer. A row with no account number (older
* writers) is still matched on id alone, as before.
*
* <p>
* All tests are correlated {@code NOT EXISTS} rather than joins, so a breakdown with no account
* transaction behind it (a Demand Draft, a manual GL entry) is untouched by them.
*/
public static final String COUNTED_ROW = " (NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt"
+ " JOIN m_savings_account rsa ON rsa.id = rt.savings_account_id"
+ " WHERE rt.id = std.txn_id"
+ " AND (std.account_no IS NULL OR rsa.account_no = std.account_no)"
+ " AND (rt.is_reversed = true OR " + breakdownDiffersFrom("rt.amount") + "))"
+ " AND NOT EXISTS ("
+ " SELECT 1 FROM m_current_account_transaction rct"
+ " JOIN m_current_account rca ON rca.id = rct.current_account_id"
+ " WHERE rct.id = std.txn_id AND rca.account_no = std.account_no"
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
+ " AND" + REAL_TXN_ID + ") ";
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
private static String breakdownDiffersFrom(final String amountColumn) {
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
}
}
@@ -286,7 +286,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -310,7 +310,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -407,7 +407,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -431,7 +431,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -0,0 +1,352 @@
/**
* 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.");
}
// The statutory form states its figures per column inside Part B rather than once at the
// top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be
// read out of it. Without this a filed statutory return listed as a row of dashes, and the
// list could not be scanned for the one return somebody was looking for.
final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull()
&& "FORM_1_STATUTORY".equals(report.get("formCode").getAsString());
// The statutory form's period runs between its two fortnight closes, which is what the
// saved list prints as the row's period.
final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart");
final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd");
// The current fortnight's figures are the second column - the close the form is made up to.
final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount");
final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount");
final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained")
: decimal(report, "maintainedAmount");
final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : 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);
}
/**
* One of a statutory part's per-column figures, taken at the column the form is made up to.
*
* The form carries three columns and the saved list has one cell per figure, so it takes the
* second - the close the return reports. Index 1 rather than "the last": the third column is
* the base the ratios are charged on, not a later position, and listing it would show a
* requirement against liabilities from a fortnight nobody filed.
*/
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
if (!report.has(part) || !report.get(part).isJsonObject()) {
return null;
}
final JsonObject partObject = report.getAsJsonObject(part);
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
return null;
}
final var values = partObject.getAsJsonArray(field);
if (values.size() < 2 || values.get(1).isJsonNull()) {
return null;
}
return values.get(1).getAsBigDecimal();
}
/** One of the statutory form's column dates, for the period a saved row reports. */
private static LocalDate columnDate(final JsonObject report, final int index) {
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
return null;
}
final var columns = report.getAsJsonArray("columns");
if (columns.size() <= index || columns.get(index).isJsonNull()) {
return null;
}
final String value = columns.get(index).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
}
@@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource {
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)");
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
if (role.startsWith("OSS1_")) {
@@ -227,17 +228,49 @@ public class RbiReserveConfigApiResource {
*/
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
/** The four treatments a head can be given, each naming a branch the returns already have. */
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO");
/**
* The treatments a head can be given, each naming a branch the returns already have.
*
* ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive
* at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be
* subtracted from the liabilities of a return that has no such base.
*/
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
"ASSET", "MEMO");
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
static {
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head");
USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL");
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward 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");
}
/**
* How a head is consumed, in the terms of the return it belongs to.
*
* LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the
* head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no
* base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL
* wording would tell a Form IX filer their head feeds a calculation that does not exist on
* their return.
*/
private static String usedBy(final String group, final String usage) {
if ("FORM9".equals(group)) {
return switch (usage == null ? "" : usage) {
case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities";
case "ASSET" -> "Form IX — an asset; enters Total Assets";
case "MEMO" -> "Form IX — stated on the form but entering no total";
default -> usage;
};
}
return USAGE_DESCRIPTIONS.getOrDefault(usage, usage);
}
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
static {
@@ -385,11 +418,14 @@ public class RbiReserveConfigApiResource {
o.put("label", row.get("label"));
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
o.put("usedBy", usedBy(group, usage));
o.put("builtIn", false);
o.put("usage", usage);
// A liability head carries the demand/time split; nothing else does.
o.put("classified", "LIABILITY".equals(usage));
// A liability head carries the demand/time split; nothing else does. Not on Form IX,
// where the demand and time heads are separate lines of the form itself - asking for a
// class on top of that would let a head declared under 5(a) Fixed Deposits be marked
// DEMAND, which the form has no way to report.
o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group));
o.put("roleId", row.get("id"));
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
out.add(o);
@@ -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,199 @@
/**
* 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 {
// The statutory form, because that is the Form 1 the screen shows and the one that gets filed.
// Saving the working report's shape left rows in the list that opened as a blank statement:
// the viewer built for the statutory form found none of the fields it reads.
private static final String FORM_CODE = "FORM_1_STATUTORY";
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.
// No column dates passed: today IS a fortnight close - the check above returned
// otherwise - so the form derives its own pair from it, which is the pair an unattended
// run should file. A filer needing a different period enters both dates on the screen.
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, 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;
}
// The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil
// on it - a bank with no balances at the State Co-operative Bank files that line empty - so
// the form is filed as it stands. What it cannot do without is a rate, because every
// required amount below is a percentage of one, and saving a return whose requirement is
// zero for want of configuration would put a compliant-looking statement on the record.
if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) {
log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.",
today);
return;
}
// The form's period runs between its two fortnight closes, and its headline figures sit per
// column inside the parts rather than once at the top.
final LocalDate periodStart = column(report, 0);
final LocalDate periodEnd = column(report, 1);
final BigDecimal average = null;
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), partFigure(report, "partA", "baseDateLiabilities"),
partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average,
decimal(report, "crrPercentage"), "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();
}
/**
* One of the statutory form's per-column figures, at the close the return is made up to.
*
* Index 1, the second column: the third is the base the ratios are charged on, not a later
* position, and listing it would show a requirement against a fortnight nobody filed.
*/
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
if (!report.has(part) || !report.get(part).isJsonObject()) {
return null;
}
final JsonObject partObject = report.getAsJsonObject(part);
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
return null;
}
final var values = partObject.getAsJsonArray(field);
if (values.size() < 2 || values.get(1).isJsonNull()) {
return null;
}
return values.get(1).getAsBigDecimal();
}
/** One of the form's column dates. */
private static LocalDate column(final JsonObject report, final int index) {
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
return null;
}
final var columns = report.getAsJsonArray("columns");
if (columns.size() <= index || columns.get(index).isJsonNull()) {
return null;
}
final String value = columns.get(index).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
@SuppressWarnings("unused")
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;
/**
* FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder
* needs (account/client/product identity, maturity date, deposit and maturity amounts).
* FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the
* real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no
* closure instruction to auto-process -- with just the fields the maturity ladder needs
* (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays
* here until it's actually renewed or closed, since that's exactly the "needs manual attention"
* state the ladder exists to surface.
*
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
@@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource {
private static final int FIXED_DEPOSIT = 200;
/** SavingsAccountStatusType.ACTIVE. */
private static final int ACTIVE = 300;
/** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */
private static final int MATURED = 800;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource {
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum = ?
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?)
ORDER BY datp.maturity_date ASC
""", FIXED_DEPOSIT, ACTIVE);
""", FIXED_DEPOSIT, ACTIVE, MATURED);
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);
}
}
@@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
* Runs the day's savings interest: accrue for every account, and post interest only from approved
* interest posting reports ({@link SavingsInterestPostingReportService}).
*
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
@@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator {
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
private final SavingsInterestPostingReportService reportService;
private final PlatformTransactionManager transactionManager;
/**
* Accrue for the day, then post for any product whose posting date has arrived.
* The nightly run: settles any approved interest report still unposted, then accrues the day.
*
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
* posting then reads and updates those same rows in a transaction of its own
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
* another's payment). Holding an outer transaction across both would mean this thread still owns
* the row locks the accrual took while the posting's separate transaction waits for them — each
* waiting on the other, and the job hanging silently with nothing in the log. So each step
* commits on its own before the next begins.
* <p>
* This run no longer decides to pay anybody. Interest is paid only from an approved posting
* report, normally when the last branch closes the day. What is left here is the safety net for a
* report that was approved but could not be posted then, and it runs BEFORE the accrual on
* purpose: by the time this job runs the business date has already moved past the posting date,
* and the accrual rolls each account's ledger row into the next cycle, which would leave nothing
* for the posting to settle.
*
* <p>
* Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the
* accrual then commits on its own, so neither holds the other's row locks.
*/
public void runForDate(final LocalDate businessDate) {
clearStaleRunningFlag();
final List<String> failures = postApprovedReports(businessDate);
accrualService.accrueForDate(businessDate);
enrolNewProducts();
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
SELECT s.savings_product_id, s.posting_period_enum
FROM m_savings_interest_posting_schedule s
JOIN m_savings_product p ON p.id = s.savings_product_id
WHERE s.is_active = true AND p.deposit_type_enum = 100
ORDER BY s.savings_product_id
""");
// Products that could not be paid, collected so the run can fail loudly at the end.
//
// Carrying on past one product is right — a mapping missing on one product must not stop
// every member of every other from being paid. Reporting the job as successful afterwards is
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
// nothing reads. On a night when every product fails, that is a whole day of interest
// silently unpaid.
final List<String> failures = new java.util.ArrayList<>();
for (final Map<String, Object> schedule : schedules) {
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
// Whether anything is due is decided inside postForProduct, not here.
//
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
// deadlocked the run: accrueForDate above is @Transactional and has just written every
// ledger row, so its transaction still holds them; the COUNT then read those rows on
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
// transaction of its own — sat waiting for a lock this thread was holding and would only
// release after postForProduct returned. Each waited on the other and the job hung
// silently, with nothing in the log and the flag left saying it was still running.
//
// postForProduct already selects what is due and returns zero when there is nothing, so
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
// than by the run date, which is what makes a missed posting date catch up on a later
// run instead of being skipped for good.
try {
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
if (paid.signum() == 0) {
updateSchedule(productId, businessDate, null);
continue;
}
updateSchedule(productId, businessDate, "COMPLETED");
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
} catch (final RuntimeException e) {
// The product rolled back on its own; record why and let the others run.
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
// the row — which is exactly what a product with no GL mapping does. The failure is
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ?
WHERE savings_product_id = ? AND posting_date = ?
""", truncate(e.getMessage()), productId, businessDate);
updateSchedule(productId, businessDate, "FAILED");
failures.add("product " + productId + ": " + e.getMessage());
}
// Keeps each product's next posting date on the timetable in step with the ledger, which
// the posting loop used to do as a side effect of looking for work.
for (final Long productId : jdbcTemplate.queryForList(
"SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) {
updateSchedule(productId, businessDate, null);
}
// Fails the job so the scheduler records it as failed and an operator is told. Every product
// that could be paid has already been paid and committed by this point — this reports what
// happened, it does not undo it.
// Fails the job so the scheduler records it as failed and an operator is told. Every report
// that could be paid has already been paid and committed by this point.
if (!failures.isEmpty()) {
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
+ failures.size() + " product(s): " + String.join("; ", failures));
+ failures.size() + " report(s): " + String.join("; ", failures));
}
}
/**
* Posts every approved interest posting report dated on or before the date.
*
* <p>
* Each report posts in a new transaction of its own. That is required, not a preference: this is
* called from the day close's after-commit callback, where the closing transaction has finished
* but is still bound to the thread, and joining it would write nothing that ever commits. It also
* keeps one product's failure from rolling back another's payment. A report that fails stays
* APPROVED with the reason recorded, and the nightly run tries it again.
*
* @return one message per report that could not be posted
*/
public List<String> postApprovedReports(final LocalDate upToDate) {
return postReports(reportService.approvedReportIds(upToDate));
}
/**
* Posts the given approved reports, each in a new transaction of its own (see
* {@link #postApprovedReports}). Used by the day close for one branch's reports.
*
* @return one message per report that could not be posted
*/
public List<String> postReports(final List<Long> reportIds) {
final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
final List<String> failures = new java.util.ArrayList<>();
for (final Long reportId : reportIds) {
final Map<String, Object> report = jdbcTemplate.queryForMap(
"SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId);
final long productId = ((Number) report.get("savings_product_id")).longValue();
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
try {
final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId));
newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED"));
log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate);
} catch (final RuntimeException e) {
log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e);
newTransaction.executeWithoutResult(status -> {
reportService.recordPostingFailure(reportId, e.getMessage());
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ?
WHERE report_id = ?
""", truncate(e.getMessage()), reportId);
updateSchedule(productId, postingDate, "FAILED");
});
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
}
}
return failures;
}
/**
* Releases this job's own currently_running flag if an earlier run left it set.
*
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
carriedInterest = backfill.cumulativeInterest();
} else {
final Map<String, Object> prev = previous.get(0);
openingBalance = (BigDecimal) prev.get("closing_balance");
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
// A cycle is finished either because it was paid out or because its end date has gone
@@ -251,11 +251,24 @@ public class SavingsDailyInterestService {
// run was missed, or the account was accrued again after a break - and treating it as
// unfinished would leave the row in a cycle that ended before the day it records.
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
carriedInterest = BigDecimal.ZERO;
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
cycleStart = prevCycleEnd.plusDays(1);
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
// The new cycle may already be days old: the business date moved on by more than a
// day, or nights were missed. Those days earned too. Starting the cycle at zero and
// counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of
// October. They are replayed from the account's transactions, as a first row is, and
// the opening balance comes from the same replay so money moved in the gap counts.
if (cycleStart.isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
openingBalance = gap.closingBalance();
carriedInterest = gap.cumulativeInterest();
} else {
openingBalance = (BigDecimal) prev.get("closing_balance");
carriedInterest = BigDecimal.ZERO;
}
} else {
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
@@ -274,12 +287,15 @@ public class SavingsDailyInterestService {
// So the gap is replayed from the account's own transactions, the same way a first
// row is built, and the result stands in for the running total the ledger would be
// holding had it run every night.
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
// The replay's balance, not the stale row's: deposits made during the gap were
// otherwise left out of every balance from here on.
openingBalance = gap.closingBalance();
carriedInterest = gap.cumulativeInterest();
} else {
openingBalance = (BigDecimal) prev.get("closing_balance");
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
}
}
@@ -506,7 +522,11 @@ public class SavingsDailyInterestService {
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
if (!cycleStart.isBefore(upTo)) {
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
// No day of the cycle to replay — it opens today — but the money held going into it is
// still there. Returning a zero balance made every re-accrual on a cycle's first day (a
// deposit, or the noon report, on the 1st) open the account at nil: an account holding
// 11,503.15 then earned only on what was deposited that day.
return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO);
}
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
@@ -0,0 +1,578 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.dailyinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* The interest posting report: what each savings account of one branch will be paid on its product's
* posting date, put in front of that branch before any money moves.
*
* <p>
* One report per branch and product. On a posting date the noon job generates one for every branch
* holding accounts with interest due; a user of that branch may generate or regenerate it at any time
* — the previous report is superseded, not overwritten — and approves it. The branch's approved
* reports are posted when that branch closes its day, and until they are approved it cannot close.
* The head office closes last, once every branch open that day has closed.
*
* <p>
* Only the branch acts on its own reports. The single exception is a branch that did not open that
* day: the head office may then generate and approve its report on its behalf, and posts it at the
* head office's own close. Every entry is still raised in the branch the customers belong to.
*
* <p>
* The figures are the daily ledger's own: each account's running interest for the cycle, rounded to
* two decimals, with the paise the rounding loses given to the largest balance so the report's total
* is the branch's pooled interest exactly.
*/
@Service
@Slf4j
public class SavingsInterestPostingReportService {
public static final String GENERATED = "GENERATED";
public static final String APPROVED = "APPROVED";
public static final String POSTED = "POSTED";
public static final String SUPERSEDED = "SUPERSEDED";
private static final int MONEY_SCALE = 2;
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
/** m_savings_account.status_enum for an active account. */
private static final int ACTIVE = 300;
/** A product with interest due in one branch. */
private record DueKey(Long productId, Long officeId) {}
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
private final TransactionTemplate transactionTemplate;
public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService,
final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.accrualService = accrualService;
this.postingService = postingService;
// Always a transaction of its own. The noon job runs inside the batch step's transaction, and
// generating in it would read the accrual through that uncommitted transaction and hold its
// row locks for the whole run; a report committed on its own is also visible to the screen at once.
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
// ── generation ───────────────────────────────────────────────────────────────────────────────
/**
* The noon job: brings the ledger up to the business date, then generates a report for every
* branch and product with interest due that does not already have one.
*
* <p>
* Each report is generated in its own transaction, so one that cannot be — a GL mapping missing,
* say — does not stop the others. The failures are raised together at the end so the job is
* recorded as failed.
*
* @return the ids of the reports generated
*/
public List<Long> generateDue(final LocalDate postingDate) {
// So each account's figure includes the posting date itself, on the balance it holds now.
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate));
final List<Long> generated = new ArrayList<>();
final List<String> failures = new ArrayList<>();
for (final DueKey due : dueKeys(postingDate, null)) {
if (activeReportId(due.productId(), due.officeId(), postingDate) != null) {
continue;
}
try {
generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null)));
} catch (final RuntimeException e) {
log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(),
due.officeId(), postingDate, e);
failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage());
}
}
if (!failures.isEmpty()) {
throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for "
+ failures.size() + " report(s): " + String.join("; ", failures));
}
return generated;
}
/**
* Generates the report afresh for one branch and product, on the user's request.
*
* <p>
* Allowed at any time of day; only the noon job waits for noon. Not for a date after the business
* date, whose interest has not been earned yet. A posted report cannot be regenerated: its money
* has moved. An approved one can, and has to be approved again.
*/
public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (postingDate.isAfter(businessDate)) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date",
"The report for " + postingDate + " cannot be generated before that date.");
}
authorize(user, officeId, businessDate);
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId()));
}
/**
* Approves a generated report. A user of the report's branch approves it; the head office may
* approve it only for a branch that did not open that day, and the report records that it did.
*/
public void approve(final Long reportId, final AppUser user) {
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
"Interest posting report " + reportId + " does not exist.");
}
final Long officeId = ((Number) found.get(0).get("office_id")).longValue();
final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate());
final int approved = jdbcTemplate.update("""
UPDATE m_savings_interest_posting_report
SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ?
WHERE id = ? AND status = 'GENERATED'
""", user.getId(), onBehalf, reportId);
if (approved == 0) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated",
"Only a report in GENERATED status can be approved. Reload the report — it may have been approved, "
+ "regenerated or posted already.");
}
}
// ── day close ────────────────────────────────────────────────────────────────────────────────
/**
* What stops one branch from closing the day: a product with interest due in the branch and no
* approved (or posted) report for the date, and any older report of the branch still awaiting
* approval — one left from a day the branch did not open. A report that was never generated
* blocks the close too.
*/
public List<String> reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
final Set<String> blocking = new LinkedHashSet<>();
for (final DueKey due : dueKeys(businessDate, officeId)) {
final Long settled = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_report
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED')
""", Long.class, due.productId(), officeId, businessDate);
if (settled == null || settled == 0) {
blocking.add(productName(due.productId()) + " (" + businessDate + ")");
}
}
for (final Map<String, Object> pending : jdbcTemplate.queryForList("""
SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ?
ORDER BY r.posting_date
""", officeId, businessDate)) {
blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")");
}
return new ArrayList<>(blocking);
}
/** The branch's approved reports, posted when it closes the day. */
public List<Long> approvedReportIds(final Long officeId, final LocalDate upToDate) {
return jdbcTemplate.queryForList("""
SELECT id FROM m_savings_interest_posting_report
WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ?
ORDER BY posting_date, savings_product_id
""", Long.class, officeId, upToDate);
}
/**
* Approved reports of branches that did not open on the date — what the head office posts at its
* own close, the branches having no close of their own that day.
*/
public List<Long> approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) {
return jdbcTemplate.queryForList("""
SELECT r.id FROM m_savings_interest_posting_report r
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?)
ORDER BY r.posting_date, r.office_id, r.savings_product_id
""", Long.class, businessDate, headOfficeId, businessDate);
}
/** Every approved report still waiting, for the nightly safety net. */
public List<Long> approvedReportIds(final LocalDate upToDate) {
return jdbcTemplate.queryForList("""
SELECT id FROM m_savings_interest_posting_report
WHERE status = 'APPROVED' AND posting_date <= ?
ORDER BY posting_date, office_id, savings_product_id
""", Long.class, upToDate);
}
/** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */
public void recordPostingFailure(final Long reportId, final String reason) {
jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?",
reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId);
}
// ── reading ──────────────────────────────────────────────────────────────────────────────────
/**
* Reports for the screen. A branch user sees only their branch; a head office user sees every
* branch, so it can act for one that did not open. canAct says whether this user may regenerate
* or approve the report.
*/
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
final AppUser user) {
final Long userOffice = officeOf(user);
final boolean headOffice = isHeadOffice(userOffice);
final Long branch = headOffice ? officeId : userOffice;
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest",
r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt",
gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy",
r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason"
FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
LEFT JOIN m_office o ON o.id = r.office_id
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
LEFT JOIN m_appuser au ON au.id = r.approved_by
WHERE 1 = 1
""");
final List<Object> params = new ArrayList<>();
if (postingDate != null) {
sql.append(" AND r.posting_date = ?");
params.add(postingDate);
}
if (status != null && !status.isBlank()) {
sql.append(" AND r.status = ?");
params.add(status.trim().toUpperCase());
} else {
sql.append(" AND r.status <> 'SUPERSEDED'");
}
if (productId != null) {
sql.append(" AND r.savings_product_id = ?");
params.add(productId);
}
if (branch != null) {
sql.append(" AND r.office_id = ?");
params.add(branch);
}
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> out = new LinkedHashMap<>(row);
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate));
rows.add(out);
}
return rows;
}
public Map<String, Object> detail(final Long reportId) {
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
o.name AS "officeName", r.posting_date AS "postingDate",
r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount",
r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy",
r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf",
r.posted_at AS "postedAt", r.failure_reason AS "failureReason",
pg.gl_code AS "payableGlCode", pg.name AS "payableGlName",
dg.gl_code AS "paidGlCode", dg.name AS "paidGlName",
eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName"
FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
LEFT JOIN m_office o ON o.id = r.office_id
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
LEFT JOIN m_appuser au ON au.id = r.approved_by
LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id
LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id
LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2
AND em.financial_account_type = 3 AND em.payment_type IS NULL
LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id
WHERE r.id = ?
""", reportId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
"Interest posting report " + reportId + " does not exist.");
}
final Map<String, Object> report = new LinkedHashMap<>(found.get(0));
report.put("lines", jdbcTemplate.queryForList("""
SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName",
l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore",
l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId"
FROM m_savings_interest_posting_report_line l
WHERE l.report_id = ?
ORDER BY l.account_no
""", reportId));
return report;
}
/**
* Branch and product interest for the head office: per report, how many customers and how much
* interest, and where it stands. Superseded reports are left out — each line is the report that
* counts for that branch, product and date.
*/
public List<Map<String, Object>> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) {
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName",
r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount",
r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy",
r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt"
FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
LEFT JOIN m_office o ON o.id = r.office_id
LEFT JOIN m_appuser au ON au.id = r.approved_by
WHERE r.status <> 'SUPERSEDED'
""");
final List<Object> params = new ArrayList<>();
if (fromDate != null) {
sql.append(" AND r.posting_date >= ?");
params.add(fromDate);
}
if (toDate != null) {
sql.append(" AND r.posting_date <= ?");
params.add(toDate);
}
if (productId != null) {
sql.append(" AND r.savings_product_id = ?");
params.add(productId);
}
sql.append(" ORDER BY r.posting_date DESC, p.name, o.name");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
* before they had anything due, or failed. Limited to what the user may act on.
*/
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user) {
final List<Map<String, Object>> out = new ArrayList<>();
// A future date has nothing due yet, and offering to generate for it would label this cycle's
// part-earned interest with a date it has not reached.
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (postingDate.isAfter(businessDate)) {
return out;
}
final Long userOffice = officeOf(user);
for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) {
if (activeReportId(due.productId(), due.officeId(), postingDate) == null
&& mayAct(userOffice, due.officeId(), businessDate)) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("productId", due.productId());
row.put("productName", productName(due.productId()));
row.put("officeId", due.officeId());
row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId()));
row.put("postingDate", postingDate.toString());
out.add(row);
}
}
return out;
}
// ── who may act ──────────────────────────────────────────────────────────────────────────────
public boolean isHeadOffice(final Long officeId) {
final List<Long> parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId);
return !parent.isEmpty() && parent.get(0) == null;
}
/** Whether the branch opened its day on the date — the head office may act only for one that did not. */
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
Long.class, officeId, businessDate);
return rows != null && rows > 0;
}
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
if (userOffice == null) {
return false;
}
if (userOffice.equals(reportOffice)) {
return true;
}
return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate);
}
/** @return true where the head office is acting on a branch's behalf */
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
final Long userOffice = officeOf(user);
if (userOffice != null && userOffice.equals(reportOffice)) {
return false;
}
if (mayAct(userOffice, reportOffice, businessDate)) {
return true;
}
final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice);
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch",
"Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day "
+ branch + " has not opened.");
}
private static Long officeOf(final AppUser user) {
return user == null || user.getOffice() == null ? null : user.getOffice().getId();
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
/**
* Branch and product pairs with interest due on or before the date: a savings product on the
* daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in
* accounts belonging to that branch.
*/
private List<DueKey> dueKeys(final LocalDate postingDate, final Long officeId) {
final StringBuilder sql = new StringBuilder("""
SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id
FROM m_savings_daily_interest d
JOIN m_savings_product p ON p.id = d.savings_product_id
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true
WHERE p.deposit_type_enum = 100
AND sa.status_enum = ?
AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
""");
final List<Object> params = new ArrayList<>(List.of(ACTIVE, postingDate));
if (officeId != null) {
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
params.add(officeId);
}
sql.append(" ORDER BY 2, 1");
return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream()
.map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue()))
.toList();
}
private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) {
final List<Long> ids = jdbcTemplate.queryForList("""
SELECT id FROM m_savings_interest_posting_report
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
""", Long.class, productId, officeId, postingDate);
return ids.isEmpty() ? null : ids.get(0);
}
private String productName(final Long productId) {
return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId);
}
/** Runs inside the caller's transaction. */
private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) {
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
SELECT id, status FROM m_savings_interest_posting_report
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
FOR UPDATE
""", productId, officeId, postingDate);
for (final Map<String, Object> report : previous) {
if (POSTED.equals(report.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted",
"Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be"
+ " regenerated.");
}
}
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED'
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED')
""", productId, officeId, postingDate);
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest,
sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
WHERE d.savings_product_id = ? AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND sa.status_enum = ?
AND COALESCE(c.office_id, g.office_id) = ?
ORDER BY d.savings_account_id
""", productId, postingDate, ACTIVE, officeId);
if (due.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due",
"No interest is due for this product in this branch on " + postingDate + ".");
}
// The GL accounts as the product maps them now; a product missing one is refused here, before
// anybody approves figures that could never be posted.
final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId);
// Round each customer's share, then make the rounded shares add up to the rounded pool: the
// residual goes to the largest balance, where a paisa is proportionally smallest.
BigDecimal grossTotal = BigDecimal.ZERO;
BigDecimal roundedTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
row.put("rounded_interest", rounded);
grossTotal = grossTotal.add(gross);
roundedTotal = roundedTotal.add(rounded);
}
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal residual = pooled.subtract(roundedTotal);
if (residual.signum() != 0) {
final Map<String, Object> largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance")))
.orElseThrow();
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
}
final Long reportId = jdbcTemplate.queryForObject("""
INSERT INTO m_savings_interest_posting_report
(savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id,
paid_gl_account_id, generation_mode, generated_by, generated_at)
VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
RETURNING id
""", Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId);
for (final Map<String, Object> row : due) {
final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal interest = (BigDecimal) row.get("rounded_interest");
jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_report_line
(report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before,
balance_after, ledger_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest,
before, before.add(interest), row.get("ledger_id"));
}
log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId,
postingDate, mode, pooled, due.size());
return reportId;
}
}
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
@@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional;
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
* holds the figure that would say so.
*
* This settles the debt the accrual has already recognised: the product's total moves out of the
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
* and only then to the members:
* The product's total is raised as an expense and credited to the liability the product names as its
* Savings Interest Payable, moves from there into the clearing account it is paid out of, and only
* then to the members:
*
* <pre>
* Dr Savings Interest Payable (liability, from the product)
* Cr Savings Interest Paid on its way out
* Dr Interest On Savings (expense, from the product) cash-accounted products only
* Cr Savings Interest Payable (liability, from the product)
*
* Dr Savings Interest Payable
* Cr Savings Interest Paid (liability clearing) on its way out
*
* Dr Savings Interest Paid (per member)
* Cr Savings Control (per member, from the product) in the member's hands
* </pre>
*
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
* this is the settlement of what that recognition already booked - raising the expense again at
* payout would put the same interest on the P&amp;L twice.
* The payable and paid accounts each close the posting at nil, so the only lasting effect is the
* expense against the members' balances, and a liability report reading the payable is unaffected.
* The first pair is skipped for an accrual-accounted product, whose accruals already raised it.
*
* Every account named above is read from the product's own GL configuration; none is written into
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
@@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService {
* Accounting tab, which is where a bank picks the account interest is owed from.
*/
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */
private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3;
/** m_savings_product.accounting_type for cash-based accounting. */
private static final int ACCOUNTING_TYPE_CASH = 2;
private final JdbcTemplate jdbcTemplate;
/** The payable and paid accounts a product's interest passes through. */
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
/**
* Posts one product's interest for one posting date.
*
* @return the amount distributed, or zero if there was nothing to do
* The payable and paid accounts, as the product maps them now.
*
* <p>
* REQUIRED, not REQUIRES_NEW.
* The product's Accounting tab is the authority: the standard Interest Payable row first, then
* the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls
* back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and
* leaves the two-legged posting a product without these mappings has always had.
*/
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
// All three are required. Falling back to Savings Control when one was left blank posted the
// interest straight from expense to the member with no payable or paid leg at all, and nobody
// could tell from the entries that the product had simply lost its mappings.
mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE));
final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID);
if (payableGl == null || paidGl == null) {
final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
productId);
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '"
+ productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid")
+ " GL mapped. Set it under the product's Accounting and generate the report again.");
}
return new PostingGlAccounts(payableGl, paidGl);
}
/**
* Posts one approved interest posting report, paying each account exactly what was approved.
*
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
* the orchestrator calls this outside any transaction of its own, so each product opens and
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
* running and waits for locks that transaction may hold. The accrual step immediately before
* writes every account's ledger row, and this method reads and updates those same rows: with
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
* and the job hung with nothing in the log and its currently_running flag left set.
* <p>
* A report belongs to one branch and one product, and every leg it raises is in that branch, so
* the branch's interest cost sits in its own P&amp;L and its payable and paid accounts close at nil
* in its own books:
*
* <pre>
* Dr Interest On Savings (branch total) → Cr Savings Interest Payable
* Dr Savings Interest Payable → Cr Savings Interest Paid
* Dr Savings Interest Paid (once) → Cr each customer of the branch
* </pre>
*
* <p>
* Nothing is recomputed. The report's lines are the authority: interest that accrued after the
* report was generated is not added, because the approver signed off on these figures and not on
* others. The payable and paid accounts are those recorded on the report when it was generated.
*
* <p>
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
* branch that was shut on its posting date posts when it next closes, into a day that is still
* open, and the narration names the cycle the interest is for.
*
* <p>
* The whole report settles in one transaction, so a failure leaves nobody half paid and the report
* still APPROVED for the next attempt.
*
* @return the amount distributed
*/
@Transactional
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
final Long completed = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_run
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
""", Long.class, productId, postingDate);
if (completed != null && completed > 0) {
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
return BigDecimal.ZERO;
public BigDecimal postReport(final long reportId) {
final List<Map<String, Object>> reports = jdbcTemplate.queryForList("""
SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id,
paid_gl_account_id
FROM m_savings_interest_posting_report
WHERE id = ?
FOR UPDATE
""", reportId);
if (reports.isEmpty()) {
throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist");
}
final Map<String, Object> report = reports.get(0);
if (!"APPROVED".equals(report.get("status"))) {
throw new IllegalStateException(
"Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
}
final long productId = ((Number) report.get("savings_product_id")).longValue();
final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
final List<Map<String, Object>> lines = jdbcTemplate.queryForList("""
SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id,
sa.currency_code
FROM m_savings_interest_posting_report_line l
JOIN m_savings_account sa ON sa.id = l.savings_account_id
WHERE l.report_id = ?
ORDER BY l.savings_account_id
""", reportId);
if (lines.isEmpty()) {
throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay");
}
final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue()
: ((Number) lines.get(0).get("office_id")).longValue();
final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
final Long payableGl = report.get("payable_gl_account_id") != null
? ((Number) report.get("payable_gl_account_id")).longValue()
: current.payableGl();
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
: current.paidGl();
final String currency = (String) lines.get(0).get("currency_code");
// One per report, and deterministic: a retried posting carries the same reference as the legs a
// failed attempt rolled back, and two branches posting the same product never share one.
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId);
final String narration = "Savings interest for cycle ending " + cycleDate;
// Stage 0: the interest cost is raised and the total credited to the payable. Nothing else
// credits it on a cash-accounted product — the daily accrual writes no journal entry. An
// accrual-accounted product has already done this through its accrual postings.
if (isCashAccounted(productId)) {
final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration);
journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration);
}
// Every account whose posting date has arrived or passed and has not yet been paid.
//
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
// run on was never paid at all — the cycle simply rolled over with the interest carried into
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
// to behave when a night is missed.
//
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
// somebody a second time.
//
// A savings account has no office of its own: it belongs to a client, or to a group when
// there is no client, and the office is theirs. The journal entries have to be raised
// against that office or they land in the wrong branch's books.
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
d.cycle_cumulative_interest AS gross_interest,
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
sa.currency_digits
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
WHERE d.savings_product_id = ? AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
ORDER BY d.savings_account_id
""", productId, postingDate);
if (due.isEmpty()) {
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
return BigDecimal.ZERO;
}
final long runId = openRun(productId, postingDate, due.size());
// Round each member's share, then make the rounded shares add up. The residual is the
// difference between what the pool holds and what the members would otherwise receive.
BigDecimal grossTotal = BigDecimal.ZERO;
BigDecimal roundedTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
row.put("rounded_interest", rounded);
grossTotal = grossTotal.add(gross);
roundedTotal = roundedTotal.add(rounded);
}
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal residual = pooled.subtract(roundedTotal);
if (residual.compareTo(BigDecimal.ZERO) != 0) {
// To the largest balance: the account where a paisa is proportionally smallest.
final Map<String, Object> largest = due.stream()
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
log.info("Savings interest posting: residual {} assigned to account {}", residual,
largest.get("savings_account_id"));
}
// The three accounts the bill passes through on its way to the members. Every one is read
// from the product's own configuration, so a bank on its own chart of accounts is posted to
// the accounts it chose. No GL code is named in this class.
//
// The product's Accounting tab is the authority, and it is consulted first for all three:
// Interest On Savings for the expense, and the standard Interest Payable row for the
// payable. Those are the rows a bank actually fills in on the product screen.
//
// The reserved names in the product's other GL mappings are consulted only after that, as a
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
// which has no standard row of its own.
//
// Where a product sets none of them the bill still has somewhere to go: the payable falls
// back to the product's Savings Control (interest owed is a liability either way), and the
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
// posting a product without these mappings has always had.
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code");
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
// and the resumed legs have to carry the same reference as the legs already posted, or one
// interest posting ends up split across two transaction ids.
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
productId);
// Stage 1: the debt the accrual has been building moves out of the payable and into the
// account the payout is made from, so what is still merely owed and what is on its way to
// the members are two separate balances rather than one.
//
// The interest expense itself is not raised here. It is recognised as the interest accrues,
// day by day, and posting is the settlement of that recognised debt — raising the expense
// again at payout would count the same interest twice on the P&L.
//
// Skipped where the product names no separate paid account: both legs then resolve to the
// payable and this would debit and credit one account for the same amount, saying nothing.
final boolean payableIsPaid = payableGl.equals(paidGl);
final boolean stage1Applies = !payableIsPaid;
// Stage 1: the total moves out of the payable and into the account the payout is made from.
// Skipped where both are the same account, which would debit and credit it for nothing.
final boolean stage1Applies = !payableGl.equals(paidGl);
if (stage1Applies) {
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration);
journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration);
}
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
// from there instead — debiting the paid account would take the money out of an account
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
// Stage 2: out to the members, one at a time.
// Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg
// carries the report's one transaction id, and every credit names its account and interest
// transaction, so who was paid what reads back from the journal alone.
journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration);
BigDecimal distributed = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final long accountId = ((Number) row.get("savings_account_id")).longValue();
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
// The member's own branch, which need not be the branch the pool was raised in.
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
newBalance);
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
accountId);
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId);
// closing_balance is raised here because the interest transaction has just been written
// and the accrual for this day has already run: nothing else will fold it in before the
// next day reads this row as its opening.
jdbcTemplate.update("""
UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
closing_balance = closing_balance + ?
WHERE id = ?
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
// Both interest columns move together, and that is not a duplicate.
//
// total_interest_earned_derived is the interest the account has earned to date;
// total_interest_posted_derived is how much of that has been paid into the balance.
// Screens and validate()'s own check read the unposted interest as earned - posted, so
// raising only `posted` drives that difference negative: an account paid 26.52 with
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
// showed exactly that. The accrual writes pending interest to the ledger and never to
// `earned`, so this posting is the only place `earned` is realised.
jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
WHERE id = ?
""", amount, amount, amount, accountId);
distributed = distributed.add(amount);
for (final Map<String, Object> line : lines) {
distributed = distributed
.add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId));
}
// The whole point of the two control accounts: if this does not hold, nothing commits.
// If the credits do not add up to the debit, nothing commits.
if (pooled.compareTo(distributed) != 0) {
throw new IllegalStateException(String.format(
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
productId, postingDate, pooled, distributed));
"Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed));
}
return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size());
}
/**
* One customer's credit: the interest transaction, the Savings Control credit naming the account,
* and the account, ledger and report line brought up to date. The branch debit is raised by the
* caller.
*
* @return the amount credited, zero for a line with nothing to pay
*/
private BigDecimal payMember(final Map<String, Object> line, final long productId, final long officeId,
final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference,
final String narration, final long runId) {
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
final long accountId = ((Number) line.get("savings_account_id")).longValue();
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
// payment, so this holds even where one account has two cycles posted on the same day.
if (line.get("savings_transaction_id") != null) {
throw new IllegalStateException("Savings account " + accountId + " was already paid by this report");
}
// The balance as it stands now, not as the report showed it: money moved since the report
// was generated, and the transaction's running balance has to follow the account.
final BigDecimal currentBalance = jdbcTemplate.queryForObject(
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
final BigDecimal newBalance = currentBalance.add(amount);
final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance);
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration);
// Settles the ledger row where it still holds the cycle the report was drawn from. Where the
// nightly run has already moved it into the next cycle — a branch that was shut on its posting
// date and posts days later — that row is the new cycle's and must not be marked paid; it only
// takes the money, because the next day's accrual opens from its closing balance.
final int settled = jdbcTemplate.update("""
UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
closing_balance = closing_balance + ?
WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ?
""", amount, transactionId, runId, amount, accountId, cycleDate);
if (settled == 0) {
jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?",
amount, accountId);
}
// Earned is only ever raised to what has now been posted, never added to: the engine may
// already have counted this interest as earned when the account was last recalculated,
// and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the
// unposted interest as earned - posted, so earned must not fall below posted either.
jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
COALESCE(total_interest_posted_derived, 0) + ?)
WHERE id = ?
""", amount, amount, amount, accountId);
jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?",
transactionId, ((Number) line.get("line_id")).longValue());
return amount;
}
private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled,
final BigDecimal distributed, final long runId, final int accountCount) {
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
WHERE id = ?
""", pooled, distributed, residual, runId);
""", pooled, distributed, runId);
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_report
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
WHERE id = ?
""", runId, reportId);
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
postingDate, distributed, due.size(), residual);
log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId,
cycleDate, distributed, accountCount);
return distributed;
}
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
/** One run per report. A retry of the same report reuses its run. */
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate,
final int accountCount) {
jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_run
(savings_product_id, posting_date, account_count, status)
VALUES (?, ?, ?, 'PENDING')
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
(report_id, savings_product_id, office_id, posting_date, account_count, status)
VALUES (?, ?, ?, ?, ?, 'PENDING')
ON CONFLICT (report_id) DO UPDATE SET
account_count = EXCLUDED.account_count, status = 'PENDING',
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
""", productId, postingDate, accountCount);
return jdbcTemplate.queryForObject("""
SELECT id FROM m_savings_interest_posting_run
WHERE savings_product_id = ? AND posting_date = ?
""", Long.class, productId, postingDate);
""", reportId, productId, officeId, cycleDate, accountCount);
return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId);
}
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
@@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService {
""", Long.class, accountId, date, INTEREST_POSTING);
}
/** The member's own leg: the product's Savings Control account, credited per account. */
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
final long transactionId) {
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
}
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
}
/**
* One journal entry leg.
*
@@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService {
*/
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) {
final Long transactionId, final String narration) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
@@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService {
created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
debit ? "Savings interest posting" : "Savings interest posted",
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration,
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
systemUserId(), systemUserId(), date);
}
@@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService {
}
/** The first configured account of those given, in order of preference. */
private boolean isCashAccounted(final long productId) {
final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?",
Integer.class, productId);
return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH;
}
private static Long firstNonNull(final Long... candidates) {
for (final Long candidate : candidates) {
if (candidate != null) {
@@ -0,0 +1,191 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.dailyinterest.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.sql.Timestamp;
import java.time.LocalDate;
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.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* The savings interest posting reports, per branch and product: list, detail, generate, approve, the
* day-close check, and the head office's branch-wise summary.
*
* <p>
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
* day, so there is exactly one way money leaves the payable.
*/
@Path("/v1/savingsinterestpostingreports")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class SavingsInterestPostingReportApiResource {
private static final String RESOURCE = "SAVINGSINTERESTREPORT";
private final SavingsInterestPostingReportService reportService;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
@GET
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList());
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
@GET
@Path("/due-without-report")
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
return gson.toJson(reportService.dueWithoutReport(date, user));
}
/**
* What stops a branch closing the business day: its own reports due and not yet approved. The
* day-close screen reads it to warn before the close is attempted.
*/
@GET
@Path("/day-close-check")
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
final AppUser user = context.authenticatedUser();
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
final List<String> blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("businessDate", businessDate.toString());
result.put("canClose", blocking.isEmpty());
result.put("pendingProducts", blocking);
return gson.toJson(result);
}
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
@GET
@Path("/summary")
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("productId") final Long productId) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
if (!reportService.isHeadOffice(user.getOffice().getId())) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only",
"The branch-wise interest summary is for the head office.");
}
return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
}
@GET
@Path("/{reportId}")
public String detail(@PathParam("reportId") final Long reportId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
final Map<String, Object> report = plain(reportService.detail(reportId));
@SuppressWarnings("unchecked")
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
report.put("lines", lines.stream().map(this::plain).toList());
return gson.toJson(report);
}
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
@POST
@Consumes({ MediaType.APPLICATION_JSON })
public String command(@QueryParam("command") final String command, final String body) {
if (!"regenerate".equalsIgnoreCase(command)) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
"Unsupported command: " + command);
}
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
final var root = fromJsonHelper.parse(body);
final Long productId = fromJsonHelper.extractLongNamed("productId", root);
final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root);
final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root);
if (productId == null) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?");
}
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
final Long reportId = reportService.regenerate(productId, officeId, date, user);
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED));
}
/** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */
@POST
@Path("/{reportId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
if (!"approve".equalsIgnoreCase(command)) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
"Unsupported command: " + command);
}
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
reportService.approve(reportId, user);
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED));
}
private static LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return LocalDate.parse(raw.trim());
} catch (final RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date",
"Dates are expected as yyyy-MM-dd, not " + raw);
}
}
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
private Map<String, Object> plain(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
row.forEach((key, value) -> {
if (value instanceof java.sql.Date d) {
out.put(key, d.toLocalDate().toString());
} else if (value instanceof Timestamp t) {
out.put(key, t.toLocalDateTime().toString());
} else {
out.put(key, value);
}
});
return out;
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class SavingsInterestPostingReportConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
.tasklet(savingsInterestPostingReportTasklet, transactionManager).build();
}
@Bean
public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
.start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer())
.build();
}
}
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Noon on the business date: generates the interest posting report for every savings product whose
* interest falls due, so it can be reviewed and approved before the branches close the day.
*/
@RequiredArgsConstructor
@Component
public class SavingsInterestPostingReportTasklet implements Tasklet {
private final SavingsInterestPostingReportService reportService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
reportService.generateDue(DateUtils.getBusinessLocalDate());
return RepeatStatus.FINISHED;
}
}
@@ -77,10 +77,13 @@ public class DepositAccountTransactionDataValidator {
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName));
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName,
DepositsApiConstants.prematureClosureReasonIdParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
@@ -245,6 +248,25 @@ public class DepositAccountTransactionDataValidator {
} else if (accountOnClosureType.isReinvest() && isPreMatureClose) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId)
.failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close");
} else if (accountOnClosureType.isTransferToGl()) {
// Only the FD premature-closure handler books the GL leg. Offering it anywhere else would
// fall through to that handler's terminal "withdraw the whole balance" case and silently pay
// the deposit out in cash, which is exactly what the partial.reinvest guard above prevents.
if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"transfer.to.gl.not.allowed",
"Transfer to a GL account is only supported for fixed-deposit premature closure");
}
final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
} else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) {
// Premature closure proceeds have to be traceable into a savings account, so the cash payout
// is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl)
// and is refused here as well -- otherwise a direct API call could still take the cash route.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"withdraw.deposit.not.allowed",
"Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account");
}
}
@@ -19,10 +19,14 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
@@ -36,6 +40,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
@@ -46,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
@@ -65,6 +71,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -73,6 +80,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -93,6 +101,8 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -303,16 +313,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest()) {
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) {
// Renews on the SAME account, so the customer keeps one account number however many times
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
// rollover itself -- which also removes the transfer-suspense round trip the old
// close-and-reopen flow needed.
// the deposit rolls over -- Reinvest Principal Only renews just the closing principal here
// (below), with only the leftover interest actually leaving, rather than the legacy
// close-and-reopen this branch used to use, which left the customer with a second account
// number for something that renews here just as cleanly as Reinvest Principal + Interest
// does. Full reinvest itself still moves nothing: the whole matured balance simply becomes
// the new term's principal, so there is no withdrawal, no deposit and no journal entry for
// the rollover itself.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance;
// Whatever doesn't renew is interest the customer earned and has to actually be paid out
// somewhere -- validated before anything below runs, so a missing target fails cleanly with
// nothing done rather than leaving a half-completed reinvestment.
final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount);
if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
throw new GeneralPlatformDomainRuleException(
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
}
// The active CBS base interest-rate version for this FD type: the same rate card the Base
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
@@ -337,66 +360,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) {
// "Re-invest principal only" still closes and reopens, because only the principal carries
// forward and the interest has to leave the deposit -- which needs a payout destination that
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
// 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.
// Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is
// zero (renewedAmount already equals maturedBalance), so this stays a no-op for it.
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -409,8 +386,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
@@ -751,13 +726,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
// Only on this first-ever close of this term -- otherwise a term that renews more than once
// (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the
// same chart against the same closingRow every time, piling up duplicate slab rows for a
// chart that only ever needs archiving once.
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
}
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow);
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
@@ -844,6 +822,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
Long savingsTransactionId = null;
// Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored
// preClosurePenalApplicable config -- this is a one-time override for this closure only, read
// fresh here since the preview and the actual submit are two separate requests.
final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName)
&& command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName);
// RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see
// PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual
// closure always matches what the preview showed for the same reason.
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType
.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName));
account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived());
// post interest
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
@@ -863,6 +853,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
} else if (closureType.isTransferToGl()) {
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
final BigDecimal proceeds = account.getAccountBalance();
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
// moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the
// net posting as Dr Savings Control / Cr chosen GL.
final boolean isInternalTransfer = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false,
isRegularTransaction, isInternalTransfer);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
command.stringValueOfParameterNamed(transferDescriptionParamName));
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
@@ -880,13 +884,92 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked:
* Dr the Liability Transfer account / Cr that GL account.
* <p>
* The withdrawal booked immediately before this was declared an account transfer, so the accounting engine
* has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair
* netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real
* createJournalEntry command rather than written straight to the ledger, so it reverses, reports and
* reconciles exactly like any other manual entry.
*/
private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount,
final LocalDate closedDate, final String description) {
final Long suspenseGlAccountId = liabilityTransferGlAccountId();
final JsonObject debitEntry = new JsonObject();
debitEntry.addProperty("glAccountId", suspenseGlAccountId);
debitEntry.addProperty("amount", amount);
final JsonArray debits = new JsonArray();
debits.add(debitEntry);
final JsonObject creditEntry = new JsonObject();
creditEntry.addProperty("glAccountId", toGlAccountId);
creditEntry.addProperty("amount", amount);
final JsonArray credits = new JsonArray();
credits.add(creditEntry);
final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber());
if (description != null && !description.isBlank()) {
comments.append(" -- ").append(description);
}
final String dateFormat = "dd MMMM yyyy";
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", account.officeId());
payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
payload.addProperty("dateFormat", dateFormat);
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", account.getCurrency().getCode());
payload.addProperty("comments", comments.toString());
payload.add("debits", debits);
payload.add("credits", credits);
final String json = payload.toString();
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
null, null, null, null, null, null, null, null, null, null, null, null, null);
this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
}
/**
* The GL account the withdrawal above credited, and therefore the exact account this entry has to debit
* to clear it.
* <p>
* It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In
* Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal
* as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping
* instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry
* collapse onto whichever account that mapping names.
* <p>
* Refusing outright when it is unmapped is deliberate: silently picking another account would put the
* closure's money somewhere nobody chose.
*/
private Long liabilityTransferGlAccountId() {
final List<Long> glAccountIds = this.jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC",
Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
if (glAccountIds.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped",
"No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure"
+ " proceeds cannot be routed to a GL account. Map it first.",
AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
}
return glAccountIds.get(0);
}
/**
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
* the existing manual Close command still uses today.
* transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and
* Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just
* Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with
* only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}
* this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's
* own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an
* already-stored instruction).
* <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
@@ -953,10 +1036,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and
// would otherwise accept any date at all -- including one behind already-posted interest, which
// the renewal would then turn into a pivot that reverses and re-posts it.
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// These three branches keep the account open, so they never reach close()/prematureClosure()
// and would otherwise accept any date at all -- including one behind already-posted interest,
// which the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
@@ -972,6 +1055,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Exactly the closing term's own principal -- the fixed point on the same split Partial
// Reinvest lets staff pick freely. Renews in place same as the other two, on just this
// account, with only the interest actually leaving (below) -- no second account, unlike the
// legacy close-and-reopen this branch used to do.
renewedAmount = closingPrincipal;
} else {
renewedAmount = maturedBalance;
}
@@ -1004,74 +1093,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// The kept portion never left the account -- renewInPlace() just relabels it as the new
// term's principal -- so only the remainder is an actual money movement, posted the same
// way the Transfer to Savings branch below posts its full-balance transfer.
// term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is
// an actual money movement, posted the same way the Transfer to Savings branch below posts
// its full-balance transfer.
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
}
} else if (onClosureType.isTransferToSavings()) {
@@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
@@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount {
protected InterestRateChartAssembler chartAssembler;
@Transient
private ConfigurationDomainService configurationDomainService;
/**
* Set once per request, immediately before a premature-closure preview or the real closure, from the
* maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is
* a one-time decision for this specific closure rather than a product/account setting. The product's
* own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so
* every other premature closure of this same product still applies its penalty as normal; this only
* suppresses it for the one request that explicitly asked to waive it.
*/
@Transient
private boolean waivePreClosurePenaltyForThisRequest;
protected FixedDepositAccount() {
//
@@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount {
if (this.chart != null) {
boolean applyPreMaturePenalty = false;
BigDecimal penalInterest = BigDecimal.ZERO;
LocalDate depositCloseDate = calculateMaturityDate();
// Rate-selection date is always calculateMaturityDate() below -- premature closure prices off
// the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one
// every regular scheduled interest posting this account ever makes (see this method's 2-arg
// overload), so a closure's freshly-recomputed total always matches what the account was
// already accruing all along. No separate "actual period held" slab lookup -- deliberately
// simple, one rate for the account's whole life.
final LocalDate depositCloseDate = calculateMaturityDate();
if (isPreMatureClosure) {
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) {
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) {
applyPreMaturePenalty = true;
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure
.depositPreClosureDetail().preClosurePenalInterestOnType();
if (preClosurePenalInterestOnType.isWholeTerm()) {
depositCloseDate = interestCalculatedUpto();
} else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) {
depositCloseDate = interestPostingUpToDate;
}
}
}
@@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount {
boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
transactions);
transactionsForCurrentTermInterestCalculation(transactions));
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
boolean isUserPosting = false;
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
@@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount {
final boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
transactions);
transactionsForCurrentTermInterestCalculation(transactions));
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
@@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount {
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
// RBI audit trail: record why, not just that, this closure happened -- see
// PrematureClosureReasonType. Absent entirely on a request from before this field existed,
// which resolves to INVALID/null here rather than a misleading "Normal".
final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName);
this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId));
/*
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
@@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount {
}
/** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview)
* or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the
* same interest calculation this waives a part of. */
public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) {
this.waivePreClosurePenaltyForThisRequest = waive;
}
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
@@ -807,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount {
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
final MathContext mc = MathContext.DECIMAL64;
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
try {
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
Money interestOnMaturity = Money.zero(this.currency);
Money interestOnMaturity = Money.zero(this.currency);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
}
return interestOnMaturity;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
return interestOnMaturity;
}
public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer,
@@ -909,12 +936,36 @@ public class FixedDepositAccount extends SavingsAccount {
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
}
/**
* Interest already posted for the term currently running, NOT the account's whole lifetime.
* <p>
* A renewed account keeps every prior term's transactions in {@code this.transactions} -- including
* whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg)
* for a term that has since closed. Summing all of them here double-counted that stale interest: a
* premature closure of the CURRENT term subtracted it from the maturity amount
* ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never
* part of this term's balance to begin with, silently shortchanging the customer by exactly that
* amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its
* deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason
* {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before
* the term restarted belongs to a term that has already been settled.
*/
private Money totalInterestPosted() {
final boolean renewedTerm = isRenewedTerm();
final LocalDate currentTermStartDate = depositStartDate();
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
if (transaction.isInterestPostingAndNotReversed()) {
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
if (!transaction.isInterestPostingAndNotReversed()) {
continue;
}
// Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the
// previous term's closing interest posting is dated the SAME day the new term starts, so an
// inclusive bound still counts it here while the posting-period walk excludes it -- leaving
// the old term's entire interest subtracted from a maturity amount it was never part of.
if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
continue;
}
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
}
return interestPostedToDate;
@@ -960,6 +1011,33 @@ public class FixedDepositAccount extends SavingsAccount {
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
}
/**
* A renewed term's opening balance is seeded straight to the term's own principal (see
* calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF
* depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing
* transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed
* and the new one opened in the same stroke), so without this filter it still falls inside the new
* term's posting-period walk and gets applied a SECOND time on top of the already-net seed --
* understating the very first day's balance by the old term's entire payout, which silently corrupts
* that day's interest for as long as the term keeps re-including it (every premature-closure preview,
* and any later maturity recalculation). Scoping to transactions strictly after the term start avoids
* the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway.
*/
private List<SavingsAccountTransaction> transactionsForCurrentTermInterestCalculation(
final List<SavingsAccountTransaction> transactions) {
if (!isRenewedTerm()) {
return transactions;
}
final LocalDate currentTermStartDate = depositStartDate();
final List<SavingsAccountTransaction> scoped = new ArrayList<>();
for (final SavingsAccountTransaction transaction : transactions) {
if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
scoped.add(transaction);
}
}
return scoped;
}
private LocalDate interestCalculatedUpto() {
LocalDate uptoMaturityDate = calculateMaturityDate();
if (uptoMaturityDate != null) {
@@ -759,17 +759,22 @@ public class RecurringDepositAccount extends SavingsAccount {
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
final MathContext mc = MathContext.DECIMAL64;
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
try {
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
Money interestOnMaturity = Money.zero(this.currency);
Money interestOnMaturity = Money.zero(this.currency);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
}
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
return interestOnMaturity;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
return interestOnMaturity;
}
@Override
@@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -19,8 +19,11 @@
package org.apache.fineract.portfolio.savings.service;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
@@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
@@ -66,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType);
DepositAccountData accountData = null;
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT,
DepositAccountOnClosureType.TRANSFER_TO_SAVINGS });
// Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed
// deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the
// counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright
// by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it
// either. TRANSFER_TO_GL is offered only for fixed deposits because only
// DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg.
final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit()
? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS,
DepositAccountOnClosureType.TRANSFER_TO_GL }
: new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS };
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes);
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -77,13 +89,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
// calculate interest before one day of closure date
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
final boolean isPreMatureClosure = true;
// Preview-time only -- the real closure re-reads this same box from its own request when it
// actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a
// maker previewing with the box checked and then submitting without it (or vice versa) gets
// exactly what they last confirmed, not what an earlier preview computed.
final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element)
&& this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element);
// RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased
// settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox
// above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so
// the preview a maker sees always matches what the actual closure will compute.
final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName,
element);
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId);
final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived();
if (depositAccountType.isFixedDeposit()) {
final FixedDepositAccount fd = (FixedDepositAccount) account;
accountData = FixedDepositAccountData.preClosureDetails(
account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty);
final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
// calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's
// own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview --
// the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here
// rather than re-deriving it, to show the maker exactly what applied.
accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions,
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
} else if (depositAccountType.isRecurringDeposit()) {
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
accountData = RecurringDepositAccountData.preClosureDetails(
@@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
// TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one
// handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in
// DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the
// normal Close and Payment screens -- would only get it refused by validateClosing.
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
@@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
// Reinvest Principal Only now renews in place on this same account (see
// DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually
// set any more, but this guard is left as a harmless no-op rather than removed, in case a future
// closure type reintroduces a genuine close-and-reopen.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
@@ -1554,7 +1555,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// an account opened straight into Reinvest Principal Only can reach here with none set.
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = fdAccount.getLinkedAccountId();
toSavingsId = resolveLinkedAccountId(fdAccount.getId());
}
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
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.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
// Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never
// actually set any more, but this guard is left as a harmless no-op rather than removed,
// in case a future closure type reintroduces a genuine close-and-reopen.
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
@@ -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
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
// should always be present, but fail clearly rather than silently stranding the interest if not.
final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId();
final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId());
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
@@ -1630,13 +1628,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
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 int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
@@ -1669,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
// REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId);
// REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that
// actually carries the new term's maturity date/amount either way.
// Both reinvest types now renew this same account in place (see
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never
// actually set any more, but this guard is left as a harmless no-op rather than removed, in case
// a future closure type reintroduces a genuine close-and-reopen.
FixedDepositAccount renewedAccount = fdAccount;
if (changes.get("reinvestedDepositId") != null) {
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
@@ -1697,17 +1690,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
"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 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<>();
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
* linked Savings/Current account's product -- credited onto the FD itself as a genuine
* INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the
* cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD
* Dashboard's generic Payment command) reads the account's balance. 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.
* Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
* dialog before the staff member confirms anything -- the actual current balance, the linked
* account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid
* onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so
* projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews.
* Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets
* zero for the gap fields since nothing is owed.
*/
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
final Long linkedAccountId = fdAccount.getLinkedAccountId();
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/Current account to it "
+ "before renewing late, so the gap-period interest has a rate to use.");
@Transactional(readOnly = true)
@Override
public Map<String, Object> previewFdRenewal(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
final Map<String, Object> result = new HashMap<>();
if (!fdAccount.isMatured()) {
result.put("isMatured", false);
return result;
}
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
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", 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
: fdAccount.getInterestCalculationDaysInYearType();
final MathContext mc = MoneyHelper.getMathContext();
final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc)
.multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc)
.setScale(2, java.math.RoundingMode.HALF_UP);
return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc)
.divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP);
}
/**
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
* linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine
* INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in
* the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at
* the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding
* it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings
* interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay
* it out through the FD's own closure math instead of it already being where it belongs. The FD's
* own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or
* the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap
* interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit}
* and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as
* a pre-step before submitting the same generic `payment` command the account's own Account Actions
* menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial
* Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint
* originally supported.
*/
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
if (linkedAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it "
+ "before renewing late, so the gap-period interest has somewhere to be paid.");
}
final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT);
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(),
today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true);
fdAccount.addTransaction(gapInterestTxn);
fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount,
linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true);
linkedAccount.addTransaction(gapInterestTxn);
linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount);
postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
}
return gapInterestAmount;
}
@@ -19,10 +19,13 @@
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingEnumerations;
@@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
}
}
private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken<List<Map<String, Object>>>() {}.getType();
private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken<Map<String, List<Map<String, Object>>>>() {}.getType();
private static List<Map<String, Object>> parseOtherGlMappings(final String otherGlMappingsJson) {
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
return null;
}
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
if (!element.isJsonArray()) {
return null;
}
return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE);
}
private static Map<String, List<Map<String, Object>>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) {
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
return null;
}
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
if (!element.isJsonObject()) {
return null;
}
return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE);
}
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
private final String schemaSql;
@@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,");
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
sqlBuilder.append("sp.accounting_type as accountingType, ");
sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, ");
sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
sqlBuilder.append("sp.withhold_tax as withHoldTax,");
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName);
}
return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
taxGroupData);
final String otherGlMappingsJson = rs.getString("otherGlMappings");
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
return data;
}
}
@@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -402,8 +410,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
msg = "Savings product with short name `" + shortName + "` already exists";
param = "shortName";
msgArgs = new Object[] { shortName, dae };
} else if (checkEx.getMessage().contains("numeric field overflow")) {
msgCode += ".amount.too.large";
msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again.";
msgArgs = new Object[] { dae };
} else {
msgCode += ".unknown.data.integrity.issue";
msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage();
msgArgs = new Object[] { dae };
}
log.error("Error occured.", dae);
@@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
return false;
}
protected Collection<Long> interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) {
if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) {
return Collections.emptyList();
}
return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId());
}
private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) {
return savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
}
protected List<SavingsAccountTransactionData> excludeInterestTransferWithdrawals(
final List<SavingsAccountTransactionData> transactions, final SavingsAccountData savingsAccountData) {
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
if (interestTransferWithdrawalIds.isEmpty()) {
return transactions;
}
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
.collect(Collectors.toList());
}
private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) {
final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData);
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
final List<SavingsAccountTransactionData> orderedNonInterestPostingTransactions = new ArrayList<>();
for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) {
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
orderedNonInterestPostingTransactions.add(transaction);
}
}
@@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted,
final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) {
final List<SavingsAccountTransactionData> filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted,
savingsAccountData);
// loop over transactions in reverse
LocalDate endOfBalanceDate = interestPostingUpToDate;
for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) {
final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i);
for (int i = filteredTransactions.size() - 1; i >= 0; i--) {
final SavingsAccountTransactionData transaction = filteredTransactions.get(i);
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) {
transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()),
@@ -436,8 +436,14 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
final Integer status, final Long maxSavingsId) {
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
// A product on the daily-interest timetable is paid by the pooled posting instead
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
// subquery so a page is never cut short by accounts that are then dropped.
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
if (backdatedTxnsAllowedTill) {
sql = sql
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
@@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -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.domain.FineractRequestContextHolder;
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.SponsorDDSlipLine;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
@@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final Long drGlAccountId;
Long linkedAccountId = null;
boolean linkedAccountIsCurrent = false;
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 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);
drGlAccountId = getCurrentControlGlId(currentAccountId);
linkedAccountId = currentAccountId;
linkedAccountIsCurrent = true;
}
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.");
@@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
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(
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
@@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
Long resultingCurrentTransactionId = null;
final Long drGlAccountId;
Long linkedAccountId = null;
boolean linkedAccountIsCurrent = false;
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
// Book debit, no journal entry of its own -- see approveIssueRequest().
@@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId);
linkedAccountId = currentAccountId;
linkedAccountIsCurrent = true;
}
if (drGlAccountId == null) {
throw new IllegalStateException(
@@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
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) {
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
* 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
* savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 /
* entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching
* how Fineract's own auto-generated entries display.
* savings/current account for an account-debit DD), the Dr-side row is tagged
* entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so
* 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,
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
final String comments, final Long linkedAccountId) {
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). */
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 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 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
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
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(
"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"
+ " ORDER BY id DESC LIMIT 1)",
linkedAccountId, transactionId);
entityType, linkedAccountId, transactionId);
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
@@ -18,7 +18,6 @@
*/
package org.apache.fineract.spm.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.core.MediaType;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
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.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
@@ -57,6 +60,8 @@ public class SpmApiResource {
private final PlatformSecurityContext securityContext;
private final SpmService spmService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@GET
@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"
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) {
this.securityContext.authenticatedUser();
final Survey survey = SurveyMapper.map(surveyData, new Survey());
this.spmService.createSurvey(survey);
return getResponse(survey.getId());
public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@PUT
@@ -116,12 +119,10 @@ public class SpmApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Transactional
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) {
this.securityContext.authenticatedUser();
final Survey surveyToUpdate = this.spmService.findById(id);
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
this.spmService.updateSurvey(survey);
return getResponse(survey.getId());
public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@@ -131,22 +132,17 @@ public class SpmApiResource {
@Transactional
@Operation(summary = "Deactivate Survey", description = "")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
this.securityContext.authenticatedUser();
public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder();
final CommandWrapper commandRequest;
if (command != null && command.equalsIgnoreCase("activate")) {
this.spmService.activateSurvey(id);
commandRequest = builder.activateSurvey(id).build();
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
this.spmService.deactivateSurvey(id);
commandRequest = builder.deactivateSurvey(id).build();
} else {
throw new UnrecognizedQueryParamException("command", command);
}
}
private String getResponse(Long id) {
Gson gson = new Gson();
HashMap<String, Object> response = new HashMap<>();
response.put("resourceId", id);
return gson.toJson(response);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -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_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() {
/*
* 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
* 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"
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
+ " 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";
}
@@ -321,6 +321,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
if (parsedElement.isJsonObject()) {
// the stored payload keeps a "[PROTECTED]" placeholder for the password rather than
// the real value; the actual new password is applied below via request.getPasswordHash(),
// so these keys must not reach AppUser.update() or it will try to re-encode the placeholder
final JsonObject payloadObject = parsedElement.getAsJsonObject();
payloadObject.remove("password");
payloadObject.remove("repeatPassword");
}
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -32,4 +32,5 @@
<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/0011_create_api_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0012_seed_default_platform_api_client.xml" relativeToChangelogFile="true"/>
</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: this is a deliberate trade-off, since 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,13 @@
<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/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/0585_form1_statutory_heads.xml" relativeToChangelogFile="true"/>
<include file="parts/0586_form9_return_heads.xml" relativeToChangelogFile="true"/>
<include file="parts/0587_form9_default_mappings.xml" relativeToChangelogFile="true"/>
<include file="parts/0588_form9_map_dividend_round_off.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
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
@@ -149,4 +156,12 @@
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
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/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_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
</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,240 @@
<?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">
<!--
The line items of the statutory Form 1 a co-operative bank actually files - Banking
Regulation (Co-operative Societies) Rules 1966, Form 1 under Rule 5, sections 18(1) and
24(3) - so each can be mapped to this bank's own GL accounts.
The return already modelled had one liabilities figure and one holdings figure. The filed
form is structured quite differently, and the difference is not cosmetic:
- Liabilities and assets are each split between the BANKING SYSTEM (part I and part III)
and everyone else (part II). The split matters because the two are netted against each
other to arrive at the figure the ratios are charged on.
- Balances with other banks are split again between current accounts (VI) and every other
kind of balance (VII), because only the current-account ones count toward the cash
reserve, while the rest count toward the liquidity requirement.
- Part VI and VII name the counterparties the rules name: the Reserve Bank, the State
Co-operative Bank and the District Central Co-operative Bank. A single "balances with
other banks" head cannot report these separately, and the form requires it.
Heads are seeded as MEMO. Each one's treatment on the return - added into the base, netted
off it, or counted toward a requirement - is a regulatory question this migration is not
the place to settle, and a seeded guess would be indistinguishable from a decision the bank
made. An administrator changes the usage as each head's role is established.
No head is mapped to an account: the right account differs per bank, and Basoda's chart is
not this tenant's.
Every changeSet is guarded so this migration is a safe no-op where already applied.
-->
<changeSet author="fineract" id="0585-1-seed-form1-banking-system-liabilities">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_I_%'
</sqlCheck>
</and>
</preConditions>
<!--
Part I: liabilities in India TO the banking system. Reported apart from liabilities to
everyone else because the banking system's claims on the bank are netted against the
bank's claims on it (part III) before the ratios are charged.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_I_A_I_SBI_CURRENT"/>
<column name="label" value="I.a.i Current account credit balances of SBI and its subsidiaries"/>
<column name="description" value="Form 1 Part I.a(i). Credit balances in current accounts that the State Bank of India, its subsidiary banks and the corresponding new banks maintain WITH this bank. A liability to the banking system, not to a depositor."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="500"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_I_A_II_OTHER_DEMAND_BS"/>
<column name="label" value="I.a.ii Other demand liabilities to the banking system"/>
<column name="description" value="Form 1 Part I.a(ii). Demand liabilities owed to banks other than those named in I.a(i)."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="501"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_I_B_TIME_BS"/>
<column name="label" value="I.b Time liabilities to the banking system"/>
<column name="description" value="Form 1 Part I.b. Time liabilities owed to the banking system."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="502"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0585-2-seed-form1-banking-system-assets">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_III_%'
</sqlCheck>
</and>
</preConditions>
<!--
Part III: assets in India WITH the banking system. Netted against part I to give the net
position, which is what the form's item IV reports.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_III_A_SBI_CURRENT"/>
<column name="label" value="III.a Current account balances with SBI and its subsidiaries"/>
<column name="description" value="Form 1 Part III.a. Credit balances THIS bank holds in current accounts with the State Bank of India, its subsidiary banks and the corresponding new banks. Item VIII reports the excess of this over I.a(i)."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="510"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_III_B_I_OTHER_BALANCES"/>
<column name="label" value="III.b.i Balances with the banking system in all other accounts"/>
<column name="description" value="Form 1 Part III.b(i). Balances held with the banking system in accounts other than those in III.a."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="511"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_III_B_II_MONEY_AT_CALL_BS"/>
<column name="label" value="III.b.ii Money at call and short notice with the banking system"/>
<column name="description" value="Form 1 Part III.b(ii). Funds lent to the banking system at call or short notice."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="512"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_III_B_IV_ADVANCES_BS"/>
<column name="label" value="III.b.iv Advances and other assets with the banking system"/>
<column name="description" value="Form 1 Part III.b(iv). Advances to the banking system and any other assets held with it."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="513"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0585-3-seed-form1-current-account-balances">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VI_%'
</sqlCheck>
</and>
</preConditions>
<!--
Part VI: balances in CURRENT accounts, by counterparty. These are what the cash reserve
is actually maintained in, alongside cash on hand, so the form names each counterparty
separately rather than reporting one combined figure.
The Reserve Bank head duplicates nothing: an existing custom head of the same name was
added ad hoc, whereas this one carries the form's own item number so the return can find
it by position rather than by what somebody typed.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_VI_A_RBI_CURRENT"/>
<column name="label" value="VI.a Balance in current account with the Reserve Bank of India"/>
<column name="description" value="Form 1 Part VI.a. Counts toward the cash reserve actually maintained (item X = V + VI + VIII)."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="520"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_VI_B_STATE_COOP_CURRENT"/>
<column name="label" value="VI.b Balance in current account with the State Co-operative Bank"/>
<column name="description" value="Form 1 Part VI.b. Current account with the State Co-operative Bank of the state concerned. Counts toward the cash reserve actually maintained."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="521"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_VI_C_DCCB_CURRENT"/>
<column name="label" value="VI.c Balance in current account with the District Central Co-operative Bank"/>
<column name="description" value="Form 1 Part VI.c. Current account with the District Central Co-operative Bank of the district concerned. Counts toward the cash reserve actually maintained."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="522"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0585-4-seed-form1-other-balances">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VII_%'
</sqlCheck>
</and>
</preConditions>
<!--
Part VII: balances of ALL OTHER types - term deposits with those banks, and government
securities. Kept apart from part VI because the two do different work: VI counts toward
the cash reserve, VII toward the liquidity requirement. Collapsing them would let a term
deposit satisfy the cash reserve, which it cannot.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_VII_A_STATE_COOP_OTHER"/>
<column name="label" value="VII.a Balances of all other types with the State Co-operative Bank"/>
<column name="description" value="Form 1 Part VII.a. Deposits and other balances with the State Co-operative Bank, other than the current account in VI.b."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="530"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_VII_B_DCCB_OTHER"/>
<column name="label" value="VII.b Balances of all other types with the District Central Co-operative Bank"/>
<column name="description" value="Form 1 Part VII.b. Deposits and other balances with the District Central Co-operative Bank, other than the current account in VI.c."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="531"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM1_VII_C_GOVT_SECURITIES"/>
<column name="label" value="VII.c Investment in Government securities"/>
<column name="description" value="Form 1 Part VII.c. Government securities held. The principal component of the assets maintained against the liquidity requirement (item XII)."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="532"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</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,908 @@
<?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">
<!--
Form IX - Return on Assets and Liabilities, return code R408.
A DIFFERENT return from Form 9 (SLR) despite the name. Form 9/SLR reports one ratio:
liabilities, the liquid assets held against them, and whether the bank complied. Form IX
reports the bank's WHOLE balance sheet - every class of liability and every class of asset -
and its two sides must agree. Filed monthly, as at the month end.
Reported in RUPEES THOUSANDS, not rupees. The conversion happens once, on the finished
figure, never per account: the filed return this was built from rounded each line
independently and came out with its two sides 2 thousand apart, then printed a
"DIFF ADJUSTED" line rather than saying so.
Every head here is seeded UNMAPPED, in role_group FORM9 so it sits under its own filter on
the configuration screen and cannot be confused with a Form 1 head. A head nobody maps
reports nil, which on this return is a real answer - a bank with no borrowings from the
EXIM Bank files that line empty.
role_usage on this return says which side of the balance sheet a head belongs to and how it
folds into the totals:
LIABILITY - a liability head; enters Total Liabilities
ASSET - an asset head; enters Total Assets
MEMO - stated on the face of the form but entering no total (the "of which" splits)
-->
<changeSet author="fineract" id="0586-0-widen-role-usage-for-asset">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_rbi_reserve_role"/>
</preConditions>
<!--
0575 constrained role_usage to four values and 0584 widened it to five. ASSET is a
sixth, and it has to be admitted BEFORE any row below can carry it - otherwise every
asset insert is rejected by the constraint rather than by anything meaningful, and the
migration aborts part-way with half the heads seeded.
ASSET is not ASSET_NET. That one is netted OFF the liability base to arrive at NDTL;
this one is added INTO Total Assets on a return that has no such base. Reusing ASSET_NET
here would have subtracted Form IX's assets from Form 1's liabilities.
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', 'ASSET', 'MEMO'));
</sql>
</changeSet>
<!-- ══════════════════ A. LIABILITIES ══════════════════ -->
<changeSet author="fineract" id="0586-1-seed-form9-capital-and-reserves">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE role_group = 'FORM9'
</sqlCheck>
</and>
</preConditions>
<!-- 1. Paid Up capital -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L1_A_CAPITAL_INDIVIDUALS"/>
<column name="label" value="1(a) Paid up capital - Individuals"/>
<column name="description" value="Share capital subscribed by individual members. In a co-operative bank this is usually the whole of the paid up capital, and it is the linking share and shareholder accounts together."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1010"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L1_B_CAPITAL_COOP_SOCIETIES"/>
<column name="label" value="1(b) Paid up capital - Co-operative Societies"/>
<column name="description" value="Share capital subscribed by co-operative societies."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1011"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L1_C_CAPITAL_STATE_GOVT"/>
<column name="label" value="1(c) Paid up capital - State Government"/>
<column name="description" value="Share capital subscribed by the State Government, including a co-operative bank holding on the State's behalf."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1012"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L1_D_CAPITAL_OTHERS"/>
<column name="label" value="1(d) Paid up capital - Others"/>
<column name="description" value="Share capital from any subscriber not covered by 1(a) to 1(c)."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1013"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 2. Reserve fund etc. -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_A_STATUTORY_RESERVE"/>
<column name="label" value="2(a) Statutory Reserves"/>
<column name="description" value="The reserve fund a co-operative bank is required by statute to build out of its profits."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1020"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_B_AGRI_CREDIT_FUND"/>
<column name="label" value="2(b) Agricultural Credit (Stabilisation) Fund"/>
<column name="description" value="Fund held against agricultural credit losses."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1021"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_C_DIVIDEND_EQUALISATION"/>
<column name="label" value="2(c) Dividend Equalisation Fund"/>
<column name="description" value="Fund set aside to even out dividend across years."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1022"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_D_SPECIAL_BAD_DEBT"/>
<column name="label" value="2(d) Special Bad Debt Reserve"/>
<column name="description" value="Reserve earmarked against specific bad debts, where the bank keeps one apart from its general bad and doubtful debt reserve."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1023"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_E_BAD_DOUBTFUL_RESERVE"/>
<column name="label" value="2(e) Bad and Doubtful Debt Reserve"/>
<column name="description" value="The bad and doubtful debt reserve TOGETHER WITH the provision held against non-performing assets. Both are mapped to this one head: a bank that maps only its bad-debt reserve here understates the line by the whole of its NPA provision, which on a filed return is the difference between a reserve that covers its advances and one that does not."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1024"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_F_INVESTMENT_DEPRECIATION"/>
<column name="label" value="2(f) Investment Depreciation Reserve"/>
<column name="description" value="Reserve held against depreciation in the value of the bank's investments."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1025"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L2_G_OTHER_FUNDS"/>
<column name="label" value="2(g) Other Funds and Reserves"/>
<column name="description" value="Every other fund the bank carries - building, staff welfare, development, charitable, computer, training, vehicle, contingent provision and the rest. Map each such account here: the return prints one row per account under its own name, which is the 'Other Funds and Reserves - Specify' sheet of the RBI workbook."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1026"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 3. Principal/Subsidiary State Partnership Fund -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L3_A_PARTNERSHIP_CCB"/>
<column name="label" value="3(a) State Partnership Fund - share capital of Central Co-operative Banks"/>
<column name="description" value="Principal or subsidiary State partnership fund held for the share capital of central co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1030"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L3_B_PARTNERSHIP_PACS"/>
<column name="label" value="3(b) State Partnership Fund - share capital of Primary Agricultural Credit Societies"/>
<column name="description" value="State partnership fund held for the share capital of primary agricultural credit societies."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1031"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L3_C_PARTNERSHIP_OTHER"/>
<column name="label" value="3(c) State Partnership Fund - share capital of Other Societies"/>
<column name="description" value="State partnership fund held for the share capital of any other society."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1032"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0586-2-seed-form9-demand-liabilities">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L4_%'
</sqlCheck>
</and>
</preConditions>
<!-- 4(a) Current deposits -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_A1_CURRENT_CCB"/>
<column name="label" value="4(a)(i) Current deposits from Central Co-operative Banks"/>
<column name="description" value="Current account deposits held for central co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1040"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_A2_CURRENT_PCB"/>
<column name="label" value="4(a)(ii) Current deposits from Primary Co-operative Banks"/>
<column name="description" value="Current account deposits held for primary co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1041"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_A3_CURRENT_SOCIETIES"/>
<column name="label" value="4(a)(iii) Current deposits from Other Societies"/>
<column name="description" value="Current account deposits held for societies other than co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1042"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_A4_CURRENT_INDIVIDUALS"/>
<column name="label" value="4(a)(iv) Current deposits from Individuals, Firms, Associations etc."/>
<column name="description" value="Current account deposits of ordinary customers. In most urban co-operative banks this is the whole of the current deposit line."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1043"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_A5_CURRENT_RESERVE_FUND"/>
<column name="label" value="4(a)(v) Current deposits - Reserve fund deposits of societies in the bank's area"/>
<column name="description" value="Reserve fund deposits that societies within the bank's area of operation are required to keep with it."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1044"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 4(b) Savings deposits - DEMAND portion -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS"/>
<column name="label" value="4(b)(iv) Savings deposits (demand portion) from Individuals, Firms, Associations etc."/>
<column name="description" value="The DEMAND share of ordinary savings balances. Map the savings account here with the demand percentage as its portion, and the same account under 5(b)(iv) with the remainder: the two shares together must be the whole balance. The split is the bank's own, settled with its auditors, and is NOT the split used on Form 1 - that return charges its ratios on a differently drawn base."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1048"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_B_SAVINGS_DEMAND_OTHER"/>
<column name="label" value="4(b)(i-iii,v) Savings deposits (demand portion) from Co-operative Banks and Societies"/>
<column name="description" value="The demand share of savings balances held for central co-operative banks, primary co-operative banks, other societies, and societies' reserve fund deposits."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1049"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 4(c) Borrowings from banks - demand -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_C_BORROWINGS_DEMAND"/>
<column name="label" value="4(c) Borrowings from banks (demand)"/>
<column name="description" value="Demand borrowings from the Reserve Bank, State Bank, Development Bank, National Bank, EXIM Bank, National Co-operative Development Corporation, State Co-operative Bank, Central Co-operative Bank or any other lender. Map each borrowing account here; the return prints one row per account under its lender's name."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1050"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 4(d) Other demand liabilities -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L4_D_OTHER_DEMAND"/>
<column name="label" value="4(d) Other Demand Liabilities"/>
<column name="description" value="Everything else the bank owes on demand - dividend payable, share capital payable, sundry creditors, suspense, staff dues, tax deducted at source, GST payable, credit balances in loan accounts, bills payable, provisions for income tax and audit fees. Map each such account here; the return prints one row per account, which is the 'Other Demand Liabilities - Specify' sheet of the RBI workbook."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1051"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0586-3-seed-form9-time-liabilities">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L5_%'
</sqlCheck>
</and>
</preConditions>
<!-- 5(a) Fixed deposits -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A1_FD_CCB"/>
<column name="label" value="5(a)(i) Fixed deposits from Central Co-operative Banks"/>
<column name="description" value="Fixed deposits held for central co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1060"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A2_FD_PCB"/>
<column name="label" value="5(a)(ii) Fixed deposits from Primary Co-operative Banks"/>
<column name="description" value="Fixed deposits held for primary co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1061"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A3_FD_SOCIETIES"/>
<column name="label" value="5(a)(iii) Fixed deposits from Other Societies"/>
<column name="description" value="Fixed deposits held for societies other than co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1062"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A4_FD_INDIVIDUALS"/>
<column name="label" value="5(a)(iv) Fixed deposits from Individuals, Firms, Associations etc."/>
<column name="description" value="Fixed deposits of ordinary customers, whatever the scheme they were taken under - single, double, or any named deposit product that is a term deposit."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1063"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A5_FD_RESERVE_FUND"/>
<column name="label" value="5(a)(v) Fixed deposits - Reserve fund deposits of societies in the bank's area"/>
<column name="description" value="Reserve fund deposits of societies within the bank's area of operation, held as term deposits."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1064"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- Of which: the maturity split the form asks for on 5(a) -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A_FD_UPTO_1YR"/>
<column name="label" value="Of 5(a): Fixed deposits up to 1 year"/>
<column name="description" value="The share of fixed deposits maturing within a year. Stated on the face of the form and entering no total - it is a split of 5(a), not an addition to it."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="1065"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_A_FD_ABOVE_1YR"/>
<column name="label" value="Of 5(a): Fixed deposits above 1 year"/>
<column name="description" value="The share of fixed deposits maturing after a year. A split of 5(a), entering no total."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="1066"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 5(b) Savings deposits - TIME portion -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS"/>
<column name="label" value="5(b)(iv) Savings deposits (time portion) from Individuals, Firms, Associations etc."/>
<column name="description" value="The TIME share of ordinary savings balances - the remainder of the account after the demand share taken at 4(b)(iv). The two together must be the whole balance, or the return reports less in savings than the bank holds."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1070"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_B_SAVINGS_TIME_OTHER"/>
<column name="label" value="5(b)(i-iii,v) Savings deposits (time portion) from Co-operative Banks and Societies"/>
<column name="description" value="The time share of savings balances held for central co-operative banks, primary co-operative banks, other societies, and societies' reserve fund deposits."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1071"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 5(c) Borrowings from banks - time -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_C_BORROWINGS_TIME"/>
<column name="label" value="5(c) Borrowings from banks (time)"/>
<column name="description" value="Term borrowings from the Reserve Bank, State Bank, Development Bank, National Bank, EXIM Bank, National Co-operative Development Corporation, State Co-operative Bank, Central Co-operative Bank or any other lender."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1072"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 5(d) Cash certificates, recurring deposits -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_D_CASH_CERT_RD"/>
<column name="label" value="5(d) Cash Certificates, Recurring Deposits etc."/>
<column name="description" value="Recurring deposits, cash certificates and the bank's named small-savings schemes - mini deposits, monthly income schemes and the like. Every term product that is not a fixed deposit belongs here."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1073"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 5(e) Other time liabilities -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L5_E_OTHER_TIME"/>
<column name="label" value="5(e) Other Time Liabilities"/>
<column name="description" value="Time liabilities not covered above, chiefly interest accrued on deposits but not yet paid. Map each such account here; the return prints one row per account, which is the 'Other Time Liabilities - Specify' sheet of the RBI workbook."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1074"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 6-9 -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L6_BRANCH_ADJUSTMENTS"/>
<column name="label" value="6. Branch Adjustments (liabilities)"/>
<column name="description" value="Net inter-branch balances standing to the credit side at the month end."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1080"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L7_OVERDUE_INTEREST_RESERVE"/>
<column name="label" value="7. Overdue Interest Reserve"/>
<column name="description" value="Interest charged on overdue advances and held in reserve rather than taken to income. It has a matching entry in Interest Receivable on the asset side, so leaving one mapped and the other not breaks the balance by exactly this figure."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1081"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_L9_BALANCE_OF_PROFIT"/>
<column name="label" value="9. Balance of Profit"/>
<column name="description" value="Profit for the year carried on the balance sheet. A loss is reported on the asset side at item 12 instead, never as a negative here."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="LIABILITY"/>
<column name="position" valueNumeric="1082"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<!-- ══════════════════ B. ASSETS ══════════════════ -->
<changeSet author="fineract" id="0586-4-seed-form9-cash-and-banks">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A%'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A1_CASH_IN_HAND"/>
<column name="label" value="1. Cash in Hand"/>
<column name="description" value="Cash the bank itself holds - vault and teller balances at the month end."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2010"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 2. Balance with banks - CURRENT -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_A_CUR_RBI"/>
<column name="label" value="2(a) Balance with the Reserve Bank of India (current)"/>
<column name="description" value="Current account balance with the Reserve Bank of India."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2020"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_B_CUR_SBI"/>
<column name="label" value="2(b) Balance with the State Bank of India and its subsidiaries (current)"/>
<column name="description" value="Current account balances with the State Bank of India, its subsidiary banks and the corresponding new banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2021"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_C_CUR_BANKING_SYSTEM"/>
<column name="label" value="2(c) Balance with the Banking System (current)"/>
<column name="description" value="Current account balances with commercial banks - the private and public sector banks the bank keeps operating, clearing, ATM, UPI, IMPS and RTGS accounts with. Not co-operative banks, which have their own lines below."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2022"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_D_CUR_STATE_COOP"/>
<column name="label" value="2(d) Balance with the State Co-operative Bank of the State concerned (current)"/>
<column name="description" value="Current account balance with the apex co-operative bank of the State."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2023"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_E_CUR_DCCB"/>
<column name="label" value="2(e) Balance with the Central Co-operative Bank of the District concerned (current)"/>
<column name="description" value="Current account balance with the district central co-operative bank."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2024"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_F_CUR_OTHER_COOP"/>
<column name="label" value="2(f) Balance with other Co-operative Banks (current)"/>
<column name="description" value="Current account balances with co-operative banks other than the State and district central co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2025"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 2. Balance with banks - SAVINGS -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_SAV_BANKING_SYSTEM"/>
<column name="label" value="2. Balance with the Banking System (savings)"/>
<column name="description" value="Savings account balances the bank holds with commercial banks. The form reports current and savings in separate columns, so a savings balance mapped to the current head would be shown in the wrong one."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2030"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A2_SAV_COOP"/>
<column name="label" value="2. Balance with Co-operative Banks (savings)"/>
<column name="description" value="Savings account balances with the State co-operative bank, the district central co-operative bank, or any other co-operative bank."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2031"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 3. Money at call and short notice -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A3_MONEY_AT_CALL"/>
<column name="label" value="3. Money at Call and Short Notice"/>
<column name="description" value="Money lent at call or short notice to the State Bank and its subsidiaries, the banking system, the State co-operative bank, the district central co-operative bank or other co-operative banks. Ordinary fixed deposits with banks do NOT belong here - they are investments at 4(g)."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2040"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0586-5-seed-form9-investments">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A4_%'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_A1_CENTRAL_GOVT_SEC"/>
<column name="label" value="4(a)(i) Investments in Central Government Securities"/>
<column name="description" value="Central Government securities, including treasury bills."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2050"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_A2_STATE_GOVT_SEC"/>
<column name="label" value="4(a)(ii) Investments in State Government Securities"/>
<column name="description" value="State Government securities. Securities held in the available-for-sale book belong here too unless the bank classifies them elsewhere."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2051"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_B_LAND_MORTGAGE_DEBENTURES"/>
<column name="label" value="4(b) Debentures of Land Mortgage Banks"/>
<column name="description" value="Debentures issued by land development or land mortgage banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2052"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_C_OTHER_TRUSTEE_SEC"/>
<column name="label" value="4(c) Other Trustee Securities"/>
<column name="description" value="Trustee securities other than Government securities and land mortgage bank debentures."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2053"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_D_SHARES_STATE_COOP"/>
<column name="label" value="4(d) Shares of the State Co-operative Banks"/>
<column name="description" value="Shareholding in the apex co-operative bank of the State."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2054"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_E_SHARES_DCCB"/>
<column name="label" value="4(e) Shares of the Central Co-operative Banks"/>
<column name="description" value="Shareholding in the district central co-operative bank."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2055"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_F_SHARES_OTHER_COOP"/>
<column name="label" value="4(f) Shares of other Co-operative Institutions"/>
<column name="description" value="Shareholding in co-operative institutions other than the State and district central co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2056"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_G1_FD_SBI"/>
<column name="label" value="4(g)(i) Fixed deposits with the State Bank of India and its subsidiaries"/>
<column name="description" value="Term deposits placed with the State Bank of India and its subsidiary banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2057"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_G2_FD_BANKING_SYSTEM"/>
<column name="label" value="4(g)(ii) Fixed deposits with the Banking System (companies)"/>
<column name="description" value="Term deposits and investments placed with commercial banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2058"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_G3_FD_STATE_COOP"/>
<column name="label" value="4(g)(iii) Fixed deposits with the State Co-operative Bank of the State concerned"/>
<column name="description" value="Term deposits and investments placed with the apex co-operative bank of the State."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2059"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_G4_FD_DCCB"/>
<column name="label" value="4(g)(iv) Fixed deposits with the Central Co-operative Bank of the district concerned"/>
<column name="description" value="Term deposits and investments placed with the district central co-operative bank."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2060"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_G5_FD_OTHER_COOP"/>
<column name="label" value="4(g)(v) Fixed deposits with other Co-operative Banks"/>
<column name="description" value="Term deposits placed with co-operative banks other than the State and district central co-operative banks."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2061"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A4_H_OTHER_INVESTMENTS"/>
<column name="label" value="4(h) Other Investments"/>
<column name="description" value="Investments outside every class above - SIDBI and other development-institution deposits, and non-SLR investments. Map each such account here; the return prints one row per account, which is the 'Others Investments - Specify' sheet of the RBI workbook."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2062"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 5. Investments out of the State Partnership Fund -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A5_PARTNERSHIP_INVESTMENTS"/>
<column name="label" value="5. Investments out of the Principal/Subsidiary State Partnership Fund"/>
<column name="description" value="Shares of central co-operative banks, primary agricultural credit societies or other societies, bought out of the State partnership fund reported at liability 3."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2070"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0586-6-seed-form9-advances-and-other-assets">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A6_%'
</sqlCheck>
</and>
</preConditions>
<!-- 6. Loans and advances -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_A_SEASONAL_AGRI"/>
<column name="label" value="6(a) Loans for Seasonal Agricultural Operations"/>
<column name="description" value="Advances for seasonal agricultural operations."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2080"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_B_MARKETING_CROPS"/>
<column name="label" value="6(b) Loans for Marketing of Crops"/>
<column name="description" value="Advances for the marketing of crops, secured or unsecured."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2081"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_C_MEDIUM_TERM_AGRI"/>
<column name="label" value="6(c) Loans for Medium Term Agricultural purposes"/>
<column name="description" value="Medium term advances for agricultural purposes."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2082"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_D_WEAVERS_SOCIETIES"/>
<column name="label" value="6(d) Loans for Weavers Societies"/>
<column name="description" value="Advances to weavers societies."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2083"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_E_INDUSTRIAL_SHORT"/>
<column name="label" value="6(e)(i) Loans for other Industrial purposes - Short Term"/>
<column name="description" value="Short term advances for industrial purposes. Map secured and unsecured accounts separately using the liability class on each mapping row, so the form can print its Secured and Unsecured columns."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2084"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_E_INDUSTRIAL_MEDIUM"/>
<column name="label" value="6(e)(ii) Loans for other Industrial purposes - Medium Term"/>
<column name="description" value="Medium term advances for industrial purposes."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2085"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_F_OTHER_SHORT"/>
<column name="label" value="6(f)(i) Loans for Other purposes - Short Term"/>
<column name="description" value="Short term advances for purposes other than agriculture and industry - cash credits, overdrafts, loans against deposits and against security, warehouse receipt finance. For an urban co-operative bank this is usually the largest line on the return."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2086"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_F_OTHER_MEDIUM"/>
<column name="label" value="6(f)(ii) Loans for Other purposes - Medium Term"/>
<column name="description" value="Medium term advances for purposes other than agriculture and industry."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2087"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_F_OTHER_LONG"/>
<column name="label" value="6(f)(iii) Loans for Other purposes - Long Term"/>
<column name="description" value="Long term advances for purposes other than agriculture and industry - term loans and property mortgage limits."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2088"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 6(g) Explanation lines - stated, entering no total -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_G_OVERDUE_ADVANCES"/>
<column name="label" value="6(g)(i) Of the above advances, those which are overdue"/>
<column name="description" value="Advances standing overdue at the month end. Stated on the face of the form as an explanation of item 6 and entering no total - it is part of the advances already counted, not an addition to them. The form's overdue percentage is computed from it."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="2089"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A6_G_PRIORITY_SECTOR"/>
<column name="label" value="6(g)(ii) Advances outstanding in respect of the Priority Sector"/>
<column name="description" value="Advances to the priority sector. An explanation of item 6, entering no total; the form's priority-sector percentage is computed from it."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="2090"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<!-- 7-12 -->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A7_BILLS_PURCHASED"/>
<column name="label" value="7. Bills Purchased and Discounted"/>
<column name="description" value="Bills the bank has purchased or discounted and not yet realised."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2100"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A8_INTEREST_RECEIVABLE"/>
<column name="label" value="8. Interest Receivable (of which overdue)"/>
<column name="description" value="Interest earned but not yet received. It matches the Overdue Interest Reserve on the liability side, so mapping one without the other breaks the balance by exactly this figure."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2101"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A9_FIXED_ASSETS"/>
<column name="label" value="9. Premises, Furniture, Fixtures and Other Fixed Assets"/>
<column name="description" value="Land and buildings, furniture and fixtures, computer hardware and software, plant and machinery, electrical installations and security systems - at their written down value."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2102"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A10_BRANCH_ADJUSTMENTS"/>
<column name="label" value="10. Branch Adjustments (assets)"/>
<column name="description" value="Net inter-branch balances standing to the debit side at the month end."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2103"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A11_I_TANGIBLE_ASSETS"/>
<column name="label" value="11(i) Other Assets - Tangible"/>
<column name="description" value="Tangible assets outside every class above - advances to employees, stationery and printing stock, deposits with utilities, accrued interest on investments."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2104"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A11_II_INTANGIBLE_ASSETS"/>
<column name="label" value="11(ii) Other Assets - Intangible"/>
<column name="description" value="Intangible assets - deferred revenue expenditure, goodwill and the like."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2105"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORM9_A12_BALANCE_OF_LOSS"/>
<column name="label" value="12. Balance of Loss"/>
<column name="description" value="Accumulated loss carried on the balance sheet. A profit is reported on the liability side at item 9 instead, never as a negative here."/>
<column name="role_group" value="FORM9"/>
<column name="role_usage" value="ASSET"/>
<column name="position" valueNumeric="2106"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</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,212 @@
<?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">
<!--
A starting set of Form IX mappings, matched by GL CODE against whatever the tenant's chart
actually holds.
Every insert is a SELECT: an account whose code is not in this chart contributes no row, so
a tenant with a different chart gets the heads it has and nothing invented for the ones it
does not. Nothing here is hardcoded into the return itself - these are ordinary rows in the
same configuration table the Admin screen writes, and a bank that disagrees with any of them
changes it there.
Deliberately a starting point and not a complete mapping. Heads with no counterpart in this
chart - the State Co-operative Bank, the district central co-operative bank, government
securities, the State partnership fund - are left unmapped and report nil, which on this
return is a real answer rather than a gap.
Guarded on the head having NO mapping yet, so a bank that has already mapped a head by hand
keeps its own choice. Re-running this never overwrites a decision somebody made.
-->
<changeSet author="fineract" id="0587-1-map-form9-capital-and-reserves">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_%'
</sqlCheck>
</and>
</preConditions>
<sql>
-- 1(a) Paid up capital - Individuals. Both share accounts: in a co-operative bank the
-- linking share and the ordinary share together ARE the members' paid up capital.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L1_A_CAPITAL_INDIVIDUALS', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('300101', '300102');
-- 2(a) Statutory Reserves.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L2_A_STATUTORY_RESERVE', id, 100, now() FROM acc_gl_account
WHERE gl_code = '300902';
-- 2(c) Dividend Equalisation Fund.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L2_C_DIVIDEND_EQUALISATION', id, 100, now() FROM acc_gl_account
WHERE gl_code = '300901';
-- 2(e) Bad and Doubtful Debt Reserve. The NPA provision AND the standard-asset
-- provision, because the form's line is the whole reserve held against advances - not
-- the bad-debt account alone. On the filed return this was drawn from, mapping only one
-- of them would have understated the line by three quarters.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('500501', '500502');
-- 2(g) Other Funds and Reserves - every other fund the bank carries.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account
WHERE gl_code = '200905';
</sql>
</changeSet>
<changeSet author="fineract" id="0587-2-map-form9-demand-liabilities">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L4_%'
</sqlCheck>
</and>
</preConditions>
<sql>
-- 4(a)(iv) Current deposits from individuals, firms and associations.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L4_A4_CURRENT_INDIVIDUALS', id, 100, now() FROM acc_gl_account
WHERE gl_code = '200102';
-- 4(b)(iv) Savings deposits, DEMAND share.
--
-- Split 11/89 against 5(b)(iv), which is the split the filed return this was drawn from
-- carries. It is NOT the 20/80 Form 1 uses: that return charges its ratios on a
-- differently drawn base, and the two splits are settled separately with the bank's
-- auditors. The two shares here must always total 100, or the return reports less in
-- savings than the bank holds.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', id, 11, now() FROM acc_gl_account
WHERE gl_code = '200101';
-- 4(d) Other Demand Liabilities - everything owed on demand that is not a deposit.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L4_D_OTHER_DEMAND', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('200203', '200204', '200401', '200402', '200501', '200502', '200503',
'200504', '200505', '200506', '200901', '200902', '200903', '200904',
'898966');
</sql>
</changeSet>
<changeSet author="fineract" id="0587-3-map-form9-time-liabilities">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L5_%'
</sqlCheck>
</and>
</preConditions>
<sql>
-- 5(a)(iv) Fixed deposits from individuals, firms and associations.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L5_A4_FD_INDIVIDUALS', id, 100, now() FROM acc_gl_account
WHERE gl_code = '200103';
-- 5(b)(iv) Savings deposits, TIME share - the remainder of the account.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', id, 89, now() FROM acc_gl_account
WHERE gl_code = '200101';
-- 5(d) Cash certificates and recurring deposits.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L5_D_CASH_CERT_RD', id, 100, now() FROM acc_gl_account
WHERE gl_code = '200201';
-- 5(e) Other Time Liabilities - interest accrued on deposits but not yet paid.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L5_E_OTHER_TIME', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('200202', '200301', '200302', '200303');
-- 9. Balance of Profit.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L9_BALANCE_OF_PROFIT', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('300903', 'BANKPROFIT01');
</sql>
</changeSet>
<changeSet author="fineract" id="0587-4-map-form9-assets">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_A%'
</sqlCheck>
</and>
</preConditions>
<sql>
-- 1. Cash in Hand - vault and teller balances.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A1_CASH_IN_HAND', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('100101', '100102');
-- 2(a) Balance with the Reserve Bank of India.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A2_A_CUR_RBI', id, 100, now() FROM acc_gl_account
WHERE gl_code = '100203';
-- 2(c) Balance with the Banking System - the commercial banks the bank keeps operating
-- accounts with. Co-operative banks have their own lines, which this chart has no
-- accounts for.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A2_C_CUR_BANKING_SYSTEM', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('100201', '100202');
-- 6(f)(i) Short term advances for other purposes - cash credit, loans against deposits
-- and warehouse receipt finance.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A6_F_OTHER_SHORT', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('100301', '100302', '100303');
-- 6(f)(ii) Medium term advances - personal loans.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A6_F_OTHER_MEDIUM', id, 100, now() FROM acc_gl_account
WHERE gl_code = '100402';
-- 6(f)(iii) Long term advances - loans against property and house loans.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A6_F_OTHER_LONG', id, 100, now() FROM acc_gl_account
WHERE gl_code IN ('100401', '100501');
-- 8. Interest Receivable.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A8_INTEREST_RECEIVABLE', id, 100, now() FROM acc_gl_account
WHERE gl_code = '100601';
-- 11(i) Other Assets, tangible - advance tax and tax deducted at source recoverable.
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_A11_I_TANGIBLE_ASSETS', id, 100, now() FROM acc_gl_account
WHERE gl_code = '100602';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?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">
<!-- RBI audit gap: a premature FD/RD closure could waive its penalty (a manual maker checkbox),
but nothing recorded WHY, and reasons that must always waive the penalty by policy (premature
extension, conversion to another term deposit, deceased settlement, nominee/legal heir
closure) were left to the maker's own judgement with no enforcement. This column is the audit
evidence: which of those reasons (or a plain "Normal" closure) applied to this specific
closure, set once by FixedDepositAccount#prematureClosure and never changed afterward.
See PrematureClosureReasonType for the stored values. -->
<changeSet author="CBS" id="0588-1-add-premature-closure-reason">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_deposit_account_term_and_preclosure"/>
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="premature_closure_reason_enum"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="premature_closure_reason_enum" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,58 @@
<?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">
<!--
The dividend round-off account, which 0587 left out.
It holds the paise dropped when a dividend is apportioned across members - a real reserve of
the bank's, small but on the balance sheet, and every rupee in it is a rupee the two sides of
Form IX disagree by while nothing reports it.
A separate changeset rather than an edit to 0587: that one has already run, and Liquibase
checksums a changeset by its contents. Editing it in place would change the checksum of a
changeset the database records as applied, and startup aborts rather than guess which version
was meant.
-->
<changeSet author="fineract" id="0588-1-map-dividend-round-off">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<!-- Only where nobody has mapped it by hand since: a bank that put it somewhere
else made that choice deliberately and keeps it. -->
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
WHERE a.gl_code = 'DIVROUNDOFF01'
</sqlCheck>
</and>
</preConditions>
<sql>
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account
WHERE gl_code = 'DIVROUNDOFF01';
</sql>
</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>
@@ -0,0 +1,204 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
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">
<!--
Savings interest is no longer posted straight off the ledger. On a product's posting date a
report is generated at noon listing what each account will be paid; an authorised user
approves it; and only the approved figures are posted, once the last branch closes the day.
The report is kept, not recomputed, because the approved list is the authority: the posting
pays exactly what was approved, account by account. Regenerating supersedes the previous
report rather than overwriting it, so what was shown and what was approved stays on record.
-->
<changeSet author="fineract" id="0589-1-savings-interest-posting-report">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_savings_interest_posting_report"/></not>
</preConditions>
<createTable tableName="m_savings_interest_posting_report">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="savings_product_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sipr_product" referencedTableName="m_savings_product"
referencedColumnNames="id"/>
</column>
<column name="posting_date" type="DATE">
<constraints nullable="false"/>
</column>
<!-- GENERATED, APPROVED, POSTED, or SUPERSEDED by a regeneration. -->
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="total_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="account_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- As the product mapped them when the report was generated, for display and checking. -->
<column name="payable_gl_account_id" type="BIGINT"/>
<column name="paid_gl_account_id" type="BIGINT"/>
<!-- AUTO from the noon job, MANUAL from a regeneration. -->
<column name="generation_mode" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="generated_by" type="BIGINT"/>
<column name="generated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="approved_by" type="BIGINT"/>
<column name="approved_at" type="TIMESTAMP"/>
<column name="posted_at" type="TIMESTAMP"/>
<column name="posting_run_id" type="BIGINT"/>
<!-- Why an approved report could not be posted; the nightly run retries it. -->
<column name="failure_reason" type="VARCHAR(1000)"/>
</createTable>
<createIndex tableName="m_savings_interest_posting_report" indexName="idx_sipr_product_date">
<column name="savings_product_id"/>
<column name="posting_date"/>
</createIndex>
<createIndex tableName="m_savings_interest_posting_report" indexName="idx_sipr_status_date">
<column name="status"/>
<column name="posting_date"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0589-2-savings-interest-posting-report-line">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_savings_interest_posting_report_line"/></not>
</preConditions>
<createTable tableName="m_savings_interest_posting_report_line">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="report_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_siprl_report"
referencedTableName="m_savings_interest_posting_report" referencedColumnNames="id"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_siprl_account" referencedTableName="m_savings_account"
referencedColumnNames="id"/>
</column>
<column name="account_no" type="VARCHAR(50)"/>
<column name="client_name" type="VARCHAR(300)"/>
<column name="office_id" type="BIGINT"/>
<column name="interest_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="balance_before" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="balance_after" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<!-- The m_savings_daily_interest row the interest was read from, updated when posted. -->
<column name="ledger_id" type="BIGINT"/>
<column name="savings_transaction_id" type="BIGINT"/>
</createTable>
<createIndex tableName="m_savings_interest_posting_report_line" indexName="idx_siprl_report">
<column name="report_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0589-3-savings-interest-posting-report-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Generate Savings Interest Posting Report'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Generate Savings Interest Posting Report"/>
<column name="display_name" value="Generate Savings Interest Posting Report"/>
<column name="short_name" value="SA_IPRPT"/>
<column name="cron_expression" value="0 0 12 1/1 * ? *"/>
<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="Generate Savings Interest Posting ReportJobDetail1 _ 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>
<changeSet author="fineract" id="0589-4-savings-interest-posting-report-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_SAVINGSINTERESTREPORT',
'REGENERATE_SAVINGSINTERESTREPORT', 'APPROVE_SAVINGSINTERESTREPORT')
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_SAVINGSINTERESTREPORT"/>
<column name="entity_name" value="SAVINGSINTERESTREPORT"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="REGENERATE_SAVINGSINTERESTREPORT"/>
<column name="entity_name" value="SAVINGSINTERESTREPORT"/>
<column name="action_name" value="REGENERATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="APPROVE_SAVINGSINTERESTREPORT"/>
<column name="entity_name" value="SAVINGSINTERESTREPORT"/>
<column name="action_name" value="APPROVE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0589-5-grant-savings-interest-posting-report-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_role_permission rp
JOIN m_permission p ON p.id = rp.permission_id
WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT',
'APPROVE_SAVINGSINTERESTREPORT')
</sqlCheck>
</preConditions>
<!-- Super user and manager-named roles, as 0581 grants its own. -->
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT',
'APPROVE_SAVINGSINTERESTREPORT')
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,80 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
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">
<!--
The interest posting report becomes per branch: each branch approves and posts its own
customers' interest, product by product, at its own day close, so every branch's books carry
their own interest cost and balance on their own.
office_id names the branch a report is for. approved_on_behalf marks a report the head office
approved for a branch that did not open that day.
A posting run was one per product and date; with a report per branch there are several, so a
run now belongs to the report it posted.
-->
<changeSet author="fineract" id="0590-1-report-office">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_savings_interest_posting_report" columnName="office_id"/></not>
</preConditions>
<addColumn tableName="m_savings_interest_posting_report">
<column name="office_id" type="BIGINT">
<constraints foreignKeyName="fk_sipr_office" referencedTableName="m_office" referencedColumnNames="id"/>
</column>
<column name="approved_on_behalf" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
<createIndex tableName="m_savings_interest_posting_report" indexName="idx_sipr_office_date">
<column name="office_id"/>
<column name="posting_date"/>
</createIndex>
</changeSet>
<!-- Reports from before this change were bank-wide; each is given the branch its accounts belong to. -->
<changeSet author="fineract" id="0590-1b-backfill-report-office">
<sql>
UPDATE m_savings_interest_posting_report r
SET office_id = (SELECT MIN(l.office_id) FROM m_savings_interest_posting_report_line l WHERE l.report_id = r.id)
WHERE r.office_id IS NULL
</sql>
</changeSet>
<changeSet author="fineract" id="0590-2-run-per-report">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_savings_interest_posting_run" columnName="report_id"/></not>
</preConditions>
<dropUniqueConstraint tableName="m_savings_interest_posting_run" constraintName="uq_savings_posting_run_product_date"/>
<addColumn tableName="m_savings_interest_posting_run">
<column name="report_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
</addColumn>
<addUniqueConstraint tableName="m_savings_interest_posting_run" columnNames="report_id"
constraintName="uq_savings_posting_run_report"/>
<createIndex tableName="m_savings_interest_posting_run" indexName="idx_sipr_run_product_date">
<column name="savings_product_id"/>
<column name="posting_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -116,7 +116,11 @@ public class CommandSourceServiceTest {
}
@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);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
@@ -125,6 +129,8 @@ public class CommandSourceServiceTest {
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
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(superUser.isCheckerSuperUser()).thenReturn(true);
@@ -132,12 +138,12 @@ public class CommandSourceServiceTest {
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, superUser, false);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(superUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
@@ -58,6 +58,9 @@ public class DepositProductData {
// accounting
protected final EnumOptionData accountingRule;
private List<Map<String, Object>> otherGlMappings;
private Map<String, List<Map<String, Object>>> otherGlMappingsMap;
protected final Map<String, Object> accountingMappings;
protected final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings;
protected final Collection<ChargeToGLAccountMapper> feeToIncomeAccountMappings;
@@ -453,4 +456,20 @@ public class DepositProductData {
public EnumOptionData getInterestCompoundingPeriodType() {
return interestCompoundingPeriodType;
}
public List<Map<String, Object>> getOtherGlMappings() {
return this.otherGlMappings;
}
public void setOtherGlMappings(List<Map<String, Object>> otherGlMappings) {
this.otherGlMappings = otherGlMappings;
}
public Map<String, List<Map<String, Object>>> getOtherGlMappingsMap() {
return this.otherGlMappingsMap;
}
public void setOtherGlMappingsMap(Map<String, List<Map<String, Object>>> otherGlMappingsMap) {
this.otherGlMappingsMap = otherGlMappingsMap;
}
}
@@ -674,7 +674,7 @@ public final class FixedDepositAccountData extends DepositAccountData {
public static FixedDepositAccountData preClosureDetails(final Long accountId, BigDecimal maturityAmount,
final Collection<EnumOptionData> onAccountClosureOptions, final Collection<PaymentTypeData> paymentTypeOptions,
final Collection<SavingsAccountData> savingsAccountDatas) {
final Collection<SavingsAccountData> savingsAccountDatas, final BigDecimal effectiveInterestRate) {
final Long groupId = null;
final String groupName = null;
@@ -689,7 +689,12 @@ public final class FixedDepositAccountData extends DepositAccountData {
final SavingsAccountStatusEnumData status = null;
final SavingsAccountApplicationTimelineData timeline = null;
final CurrencyData currency = null;
final BigDecimal nominalAnnualInterestRate = null;
// Not the account's stored contracted rate -- the rate actually resolved for THIS premature
// closure preview (see FixedDepositAccount#getEffectiveInterestRateAsFraction, read back
// straight after the calculation by the caller), so a maker can see which of the RBI-default
// actual-period rate, a bank's "use contracted rate" override, and any penalty deduction ended
// up applying, rather than only the final rupee amount.
final BigDecimal nominalAnnualInterestRate = effectiveInterestRate;
final EnumOptionData interestPeriodType = null;
final EnumOptionData interestPostingPeriodType = null;
final EnumOptionData interestCalculationType = null;
@@ -244,7 +244,7 @@ public final class FixedDepositProductData extends DepositProductData {
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<TaxGroupData> taxGroupOptions = null;
return new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName,
final FixedDepositProductData result = new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName,
depositProductData.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate,
depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType,
depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType,
@@ -258,6 +258,9 @@ public final class FixedDepositProductData extends DepositProductData {
minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm,
inMultiplesOfDepositTermType, minDepositAmount, depositAmount, maxDepositAmount, periodFrequencyTypeOptions,
depositProductData.withHoldTax, depositProductData.taxGroup, taxGroupOptions);
result.setOtherGlMappings(depositProductData.getOtherGlMappings());
result.setOtherGlMappingsMap(depositProductData.getOtherGlMappingsMap());
return result;
}
public static FixedDepositProductData lookup(final Long id, final String name) {
@@ -609,6 +612,8 @@ public final class FixedDepositProductData extends DepositProductData {
this.balanceFrequency = src.balanceFrequency;
this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers;
this.renewalGracePeriodDays = src.renewalGracePeriodDays;
this.setOtherGlMappings(src.getOtherGlMappings());
this.setOtherGlMappingsMap(src.getOtherGlMappingsMap());
}
private Boolean withdrawalFeeForTransfers;
@@ -45,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
@@ -118,6 +119,20 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
this.fdAccountNumber = fdAccountNumber;
}
// RBI audit evidence: why THIS closure happened, set once by FixedDepositAccount#prematureClosure
// and never changed afterward -- see PrematureClosureReasonType. Null on an account that was never
// prematurely closed (including one still active), or on one closed before this column existed.
@Column(name = "premature_closure_reason_enum")
private Integer prematureClosureReason;
public PrematureClosureReasonType getPrematureClosureReason() {
return PrematureClosureReasonType.fromInt(this.prematureClosureReason);
}
public void updatePrematureClosureReason(final PrematureClosureReasonType reason) {
this.prematureClosureReason = (reason == null || reason.isInvalid()) ? null : reason.getValue();
}
// Which renewal cycle of one logical FD this account represents: 0 = originally opened account,
// 1 = first renewal, and so on. Lets the UI present "one FD, many terms" over what is physically
// a chain of per-term account rows.
@@ -52,6 +52,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPe
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minBalanceForInterestCalculationParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.otherGlMappingsParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -247,6 +248,10 @@ public class DepositProductAssembler {
fixedDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers"));
}
if (command.hasParameter(otherGlMappingsParamName)) {
fixedDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName));
}
fixedDepositProduct.validateDomainRules();
return fixedDepositProduct;
@@ -402,6 +407,10 @@ public class DepositProductAssembler {
recurringDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers"));
}
if (command.hasParameter(otherGlMappingsParamName)) {
recurringDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName));
}
recurringDepositProduct.validateDomainRules();
return recurringDepositProduct;
@@ -158,7 +158,15 @@ public final class ProductPendingEditFieldMapper {
present.add("taxGroupId");
}
if (command.hasParameter("otherGlMappings")) {
f.setOtherGlMappings(command.stringValueOfParameterNamed("otherGlMappings"));
// otherGlMappings arrives as a nested JSON object ({assets:[...], liabilities:[...],
// expenses:[...]}), the same shape SavingsProduct#update() reads via jsonFragment() --
// NOT a JSON string primitive. stringValueOfParameterNamed() only handles the latter and
// silently came back blank for the former, so every "Savings Interest Payable"/"Savings
// Interest Paid" (and other named GL account) edit was staged with an empty value: it
// never reached the pending-edit row, so the checker's diff had nothing to show and
// approving replayed no GL account change at all. jsonFragment() is the same call the
// live (non-pending) update path already uses for this exact parameter.
f.setOtherGlMappings(command.jsonFragment("otherGlMappings"));
present.add("otherGlMappings");
}
if (command.hasParameter("isDormancyTrackingActive")) {
@@ -355,7 +363,7 @@ public final class ProductPendingEditFieldMapper {
putNumber(root, submitted, "minBalanceForInterestCalculation", f.getMinBalanceForInterestCalculation());
putBoolean(root, submitted, "withHoldTax", f.getWithHoldTax());
putNumber(root, submitted, "taxGroupId", f.getTaxGroupId());
putString(root, submitted, "otherGlMappings", f.getOtherGlMappings());
putJsonFragment(root, submitted, "otherGlMappings", f.getOtherGlMappings());
putBoolean(root, submitted, "isDormancyTrackingActive", f.getIsDormancyTrackingActive());
putNumber(root, submitted, "daysToInactive", f.getDaysToInactive());
putNumber(root, submitted, "daysToDormancy", f.getDaysToDormancy());
@@ -733,6 +741,24 @@ public final class ProductPendingEditFieldMapper {
}
}
/**
* Same as {@link #putString}, but for a value that is itself raw JSON (an object/array captured
* via {@code JsonCommand#jsonFragment}) rather than a plain string. {@code addProperty} would wrap
* it in quotes as an escaped string literal, which {@code jsonFragment} on the replayed command
* would then hand back as that literal quoted text instead of the original structure -- breaking
* the downstream `other_gl_mappings::jsonb` cast this field's value round-trips through.
*/
private static void putJsonFragment(final JsonObject root, final Set<String> submitted, final String name, final String rawJson) {
if (!submitted.contains(name)) {
return;
}
if (rawJson == null || rawJson.isBlank()) {
root.add(name, com.google.gson.JsonNull.INSTANCE);
return;
}
root.add(name, com.google.gson.JsonParser.parseString(rawJson));
}
private static void putNumber(final JsonObject root, final Set<String> submitted, final String name, final Number value) {
if (submitted.contains(name)) {
root.addProperty(name, value);

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